openvibers/openvibe.vision Commits

Unsafe-image review: three categories, an operator queue and the API consumers call (#88)

OP OpenVibers committed 7 days ago · 665757e0af · 21 files, +1287 −22

* Unsafe-image review: three categories, an operator queue, and the API Media, Live and Community use

* Account export and deletion cover image reviews and their operator reports

* Review follow-up: fail-closed images, first-party previews, and a logged 60-second operator evidence view

* Docs: name openvibe-contracts v0.294.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 152edb5..3993bcf 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,19 @@
What changed in OpenVibe.Vision, newest first.
++## Unreleased — VIS-6
++
++- Review follow-up: the image pass is fail-closed (harmless text can no longer clear an image it could not judge;
++ only sex_acts/minor_sexual text may answer, otherwise 503), `preview_url` is https on a first-party host only, and
++ the operator's page withholds category-2/3 pictures until a logged 60-second Media evidence view. Adds
++ `vision_review_reveals` and `object_app`/`media_object_id`/`media_app` on a review; Contracts v0.294.0.
++- Added the unsafe-image review: exactly three categories in plain words (`fine` including nudity, `sex_acts` never
++ public, `minor_sexual` always blocked and reported to the operator), `POST /api/v1/review` for Media, Live and
++ Community, a staff queue at `/review`, one review page with the same three choices and the operator report at
++ `/review/reports`. Category 3 always makes one `vision_operator_reports` row; nothing is reported outside OpenVibe.
++ The review stores no image bytes, only an object reference, a sha256 and the model provenance. Migration
++ `0007_unsafe_image_reviews.sql`; tests `test/review.test.js`.
++
## Unreleased — VIS-5
- Added a Streamable HTTP MCP endpoint with eleven tools, protected-resource metadata, token challenges and the
ddiff --git a/README.md b/README.md
iindex 6f0e910..8dc9007 100644
---- a/README.md
++++ b/README.md
@@@ -104,7 +104,7 @@ General answer tables store no image bytes, token or provider key. The opt-in fa
## Capabilities and grants
--Contracts 0.240.0 registers twenty Vision capabilities. The service checks each machine token's grant on its route;
++Contracts registers Vision's capabilities; the review routes add three more. The service checks each machine token's grant on its route;
private media and face identity operations also require the verified owner or the person's purpose-scoped consent.
| Capability | Routes | Typical caller |
@@@ -123,6 +123,8 @@ private media and face identity operations also require the verified owner or th
| `vision.face.detect`, `vision.face.blur` | `POST /api/v1/faces/detect`, `/blur` | Public privacy tools; a service token needs the matching grant |
| `vision.face.consent.manage`, `vision.face.verify.self`, `vision.face.find.self`, `vision.face.library`, `vision.face.cast.read` | `/api/v1/faces/*` | The person themselves, with separate signed consent for each matching purpose |
| `vision.models.read`, `vision.benchmarks.read` | `GET /api/v1/models`, `/benchmarks` | Public reads; a service token needs the matching grant |
++| `vision.image.review` | `POST /api/v1/review` | Media, Live and Community (the unsafe-image review) |
++| `vision.review.read`, `vision.review.decide` | `GET /api/v1/review`, `GET /api/v1/review/:id`, `POST /api/v1/review/:id/decide`, `GET /api/v1/review/reports` | OpenVibe staff, and an operator's own service token |
Outgoing: **`ai.run.create`** to OpenVibe.AI (audience `openvibe.ai`, namespace `ai.*`), minted by Network for the
client `vision`. No new AI capability, no provider key, no direct paid provider.
@@@ -162,6 +164,11 @@ must hold the route's capability (403 `capability.denied`).
| `GET /api/v1/models` | AI's offered `provider:model` hints and the local engine. No subject data. |
| `GET /api/v1/benchmarks?task=&limit=` | aggregate compare samples: task, engine, provider, model, samples, mean latency, mean cost. No subject or answer text. |
| `GET /api/v1/ping`, `/api/health`, `/api/ready`, `/release.json`, `/metrics` | service endpoints; readiness and metrics are truthful, metrics loopback-only. |
++| `POST /api/v1/review` | `{ image_url }`, an image, or `{ text }` plus `{ object_type, object_id, owner_subject?, preview_url? }`; returns `{ category, plain_words, action, public, status, review, report? }`. Exactly one of `fine`, `sex_acts`, `minor_sexual`. |
++| `GET /api/v1/review?category=&status=` | the operator's queue (staff or `vision.review.read`); `GET /api/v1/review/:id` is the owner's or staff's single row. |
++| `POST /api/v1/review/:id/decide` | staff or `vision.review.decide`: `{ category }` must be one of the same three. |
++| `POST /api/v1/review/:id/reveal` | staff only: one 60-second Media operator view, logged on the report. |
++| `GET /api/v1/review/reports?status=` | the operator report rows made by category 3. |
Example — the call Design's Arena makes:
@@@ -193,6 +200,41 @@ Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mo
`faces.enrolment_required`, `faces.one_face_required`, `faces.model_unavailable`, `faces.key_unavailable`,
`faces.live_grant_required`.
++## Unsafe-image review (VIS-6)
++
++An image review sorts into exactly three categories, in the owner's words:
++
++| Category | Plain words | Action |
++|---|---|---|
++| `fine` | Fine — OK to show, including nudity | `allow`; nothing changes |
++| `sex_acts` | Sex acts or porn — never public; kept for its owner | `hide`; every public surface is closed and the object stays with its owner |
++| `minor_sexual` | Sexual content involving a minor — always blocked and reported to the operator | `quarantine_and_report`; the object is blocked, a report row is made for the operator, and nothing is sent outside OpenVibe |
++
++`POST /api/v1/review` accepts an image (multipart `image`, data URL, or `image_url`) or text. Text is classified by the
++same rule Live's auto-clip guard uses: nudity alone is fine; sex acts and porn are not; a child with nudity or a sex
++act is the hard line. An image is classified by OpenVibe.AI with one strict JSON question; the text answer can make
++the result more severe but never less. Category 3 always creates one `vision_operator_reports` row. The staff pages
++`/review`, `/review/:id` and `/review/reports` show the queue and let an operator decide one of the same three
++categories; a decision writes `reviewed_by`/`reviewed_at` and dismisses a report the operator overrules. A decision on
++a Media object also calls Media's `review-decision` route, which releases or places Media's evidence hold.
++
++An image the reviewer cannot judge is never cleared by harmless text: if the image pass is unavailable or returns no
++usable JSON, text may answer only when it is already `sex_acts` or `minor_sexual`; otherwise the answer is a 503 and
++the caller keeps the object pending. `preview_url` must be `https` on `openvibe.vision`, `openvibe.network` or
++`openvibe.media` (the origins its CSP already trusts); anything else is a 400. The page escapes it in `src` and `href`.
++
++Category 2 and 3 pictures are withheld from the operator by default. The queue shows no picture for category 3 and
++blurs a category-2 thumbnail; the detail page shows a blurred or withheld placeholder, not the bytes. "Show the
++picture" asks Media for a 60-second `media.object.review.view` URL (a separate signature purpose that opens a
++quarantine), logs the operator and time in `vision_review_reveals`, and shows that one picture. The warning on the
++page says the picture is evidence for the report and must not be saved or shared. Child-safety hash matching
++(Cloudflare's CSAM Scanning Tool, NCMEC lists) is a separate service and not this language model.
++
++No image bytes are stored. The review row keeps the caller's object reference (`object_app`, and the Media
++`media_object_id`/`media_app` when there is one), a sha256, the category, the plain words, the action, the model
++provenance and an optional first-party preview URL. A review does not publish a `vision.result.ready` event; the
++caller already has the row.
++
## Pages
All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, semantic HTML
@@@ -215,6 +257,9 @@ person is `private, no-store`.
| `/people/privacy` | the face notice: retention, erasure, liveness and what never happens |
| `/privacy` | the shared legal privacy policy |
| `/developers` | the API, capabilities and what leaves the host |
++| `/review` | the unsafe-image queue, staff only: three category tabs and the report count |
++| `/review/:id` | one review: the image, its provenance, the three plain choices and the operator report |
++| `/review/reports` | the operator report rows (open, resolved, dismissed) |
| 404 | the not-found page |
`public/css/vision.css` and `public/js/vision.js` are one-comment placeholder files: the pages work without them and
@@@ -277,7 +322,7 @@ Private embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a
Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.
<!-- versions:start -->
--- openvibe-contracts: v0.289.1
++- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.114.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex ab1ac43..b58fb2e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,15 +4,15 @@
"stage": "product",
"code": true,
"wave": "W21",
-- "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call; §5.8b adds faces with separate purpose-scoped consent; F1 (L24) adds a camera owner's own familiar-face libraries; wave 1 adds the six network tools",
++ "plan": "OpenVibe.Vision v1 (plan 2026-10-28 \u00a75.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call; \u00a75.8b adds faces with separate purpose-scoped consent; F1 (L24) adds a camera owner's own familiar-face libraries; wave 1 adds the six network tools",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)",
"port": "5130",
"domain": "openvibe.vision",
-- "contracts": "openvibe-contracts v0.289.1",
++ "contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.114.0",
"packages": [
-- "openvibe-contracts v0.289.1",
++ "openvibe-contracts v0.294.0",
"openvibe-sdk v0.82.0",
"openvibe-shared v4.114.0"
],
@@@ -36,7 +36,10 @@
"vision.face.library",
"vision.face.cast.read",
"vision.models.read",
-- "vision.benchmarks.read"
++ "vision.benchmarks.read",
++ "vision.image.review",
++ "vision.review.read",
++ "vision.review.decide"
],
"outboundCapabilities": [
"ai.run.create",
@@@ -58,6 +61,7 @@
"F1 familiar faces: a camera owner's own library (face_libraries, names, encrypted crops and templates); strict regions check Network's face.recognize registry before a template exists, a private link covers the rest, a match reads only that library, and delete removes templates and crops",
"Encrypted face templates, opt-in uploads and vectors (AES-256-GCM); immediate template/tag erasure on opt-out and a daily three-year retention job",
"Network tools: routed alt text, owner-scoped CLIP index/find, Tesseract-first receipts, product vectors, DETR watch and moments, each with a receipt",
++ "Unsafe-image review: exactly three categories (fine including nudity; sex acts or porn never public; sexual content involving a minor blocked, quarantined and reported to the operator), a staff queue page, an operator decision, and a plain-words API for Media, Live and Community",
"MCP on /mcp with RFC 9728 protected-resource metadata, token challenges and route-equivalent capability checks",
"vision.result.ready and vision.alert published through OpenVibe.Events when configured, through a durable outbox: a failed publish waits in event_outbox and is retried with backoff",
"Answers, compare samples, private index and UI checks stored without image bytes; account export/deletion over every table including faces and the private index",
@@@ -88,6 +92,7 @@
"account export and deletion",
"Network sign-in",
"server-rendered pages",
++ "unsafe-image review queue and three-category API",
"discovery",
"deploy files"
],
ddiff --git a/migrations/0007_unsafe_image_reviews.sql b/migrations/0007_unsafe_image_reviews.sql
nnew file mode 100644
iindex 0000000..6b1cf4f
---- /dev/null
++++ b/migrations/0007_unsafe_image_reviews.sql
@@@ -0,0 +1,88 @@
++-- phase: expand
++-- VIS-6's last item: an unsafe-image review sorts into exactly three categories.
++-- fine allowed, including nudity
++-- sex_acts never public; kept for its owner, hidden from every public surface
++-- minor_sexual always blocked; the object is quarantined and an operator report row is made
++-- No image bytes are stored. A review keeps the caller's object reference, a sha256 as evidence, the
++-- category and the engine's provenance. A category-3 review always has one operator report row; the
++-- report is internal to OpenVibe and never sent anywhere automatically.
++CREATE TABLE vision_image_reviews (
++ id text COLLATE "C" PRIMARY KEY, -- vir_ + ULID
++ subject text COLLATE "C", -- the person who submitted it, when there is one
++ requester text COLLATE "C" NOT NULL,
++ object_type text COLLATE "C" NOT NULL DEFAULT 'unknown',
++ object_id text COLLATE "C",
++ object_app text COLLATE "C",
++ media_object_id text COLLATE "C",
++ media_app text COLLATE "C",
++ owner_subject text COLLATE "C",
++ image_sha256 text COLLATE "C",
++ image_mime text COLLATE "C",
++ image_bytes bigint,
++ preview_url text,
++ category text COLLATE "C" NOT NULL,
++ plain_words text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ is_public boolean NOT NULL DEFAULT false,
++ reason text,
++ cue text COLLATE "C",
++ engine text COLLATE "C" NOT NULL,
++ model text COLLATE "C",
++ provider text COLLATE "C",
++ route text COLLATE "C",
++ confidence numeric(5, 4),
++ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,
++ latency_ms integer NOT NULL DEFAULT 0,
++ status text COLLATE "C" NOT NULL DEFAULT 'auto',
++ reviewed_by text COLLATE "C",
++ reviewed_at timestamptz,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT vision_image_reviews_id CHECK (id ~ '^vir_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_image_reviews_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_image_reviews_owner CHECK (owner_subject IS NULL OR owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_image_reviews_requester CHECK (length(requester) BETWEEN 1 AND 160),
++ CONSTRAINT vision_image_reviews_object_type CHECK (length(object_type) BETWEEN 1 AND 80),
++ CONSTRAINT vision_image_reviews_object_id CHECK (object_id IS NULL OR length(object_id) <= 300),
++ CONSTRAINT vision_image_reviews_object_app CHECK (object_app IS NULL OR length(object_app) BETWEEN 1 AND 80),
++ CONSTRAINT vision_image_reviews_media_object_id CHECK (media_object_id IS NULL OR media_object_id ~ '^med_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_image_reviews_media_app CHECK (media_app IS NULL OR length(media_app) BETWEEN 1 AND 80),
++ CONSTRAINT vision_image_reviews_category CHECK (category IN ('fine', 'sex_acts', 'minor_sexual')),
++ CONSTRAINT vision_image_reviews_action CHECK (action IN ('allow', 'hide', 'quarantine_and_report')),
++ CONSTRAINT vision_image_reviews_engine CHECK (engine IN ('text', 'ai', 'text+ai')),
++ CONSTRAINT vision_image_reviews_status CHECK (status IN ('auto', 'decided')),
++ CONSTRAINT vision_image_reviews_sha CHECK (image_sha256 IS NULL OR image_sha256 ~ '^[0-9a-f]{64}$'),
++ CONSTRAINT vision_image_reviews_mime CHECK (image_mime IS NULL OR image_mime ~ '^image/[a-z0-9.+-]+$'),
++ CONSTRAINT vision_image_reviews_bytes CHECK (image_bytes IS NULL OR (image_bytes BETWEEN 1 AND 20971520)),
++ CONSTRAINT vision_image_reviews_confidence CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)),
++ CONSTRAINT vision_image_reviews_cost CHECK (cost_usd >= 0),
++ CONSTRAINT vision_image_reviews_latency CHECK (latency_ms >= 0),
++ CONSTRAINT vision_image_reviews_preview CHECK (preview_url IS NULL OR preview_url ~ '^https?://')
++);
++CREATE INDEX vision_image_reviews_recent ON vision_image_reviews (created_at DESC);
++CREATE INDEX vision_image_reviews_by_category ON vision_image_reviews (category, created_at DESC);
++CREATE INDEX vision_image_reviews_by_owner ON vision_image_reviews (owner_subject, created_at DESC);
++CREATE INDEX vision_image_reviews_by_object ON vision_image_reviews (object_type, object_id, created_at DESC);
++CREATE INDEX vision_image_reviews_by_sha ON vision_image_reviews (image_sha256, created_at DESC);
++
++-- The operator's record: one per category-3 review (a UNIQUE review_id makes a retry harmless). It is
++-- internal; resolving it is the operator's action, and nothing is reported externally by this service.
++CREATE TABLE vision_operator_reports (
++ id text COLLATE "C" PRIMARY KEY, -- vor_ + ULID
++ review_id text COLLATE "C" NOT NULL UNIQUE REFERENCES vision_image_reviews (id) ON DELETE CASCADE,
++ category text COLLATE "C" NOT NULL DEFAULT 'minor_sexual',
++ object_type text COLLATE "C" NOT NULL DEFAULT 'unknown',
++ object_id text COLLATE "C",
++ owner_subject text COLLATE "C",
++ reason text,
++ status text COLLATE "C" NOT NULL DEFAULT 'open',
++ created_at timestamptz NOT NULL DEFAULT now(),
++ resolved_at timestamptz,
++ resolved_by text COLLATE "C",
++ CONSTRAINT vision_operator_reports_id CHECK (id ~ '^vor_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_operator_reports_category CHECK (category IN ('minor_sexual')),
++ CONSTRAINT vision_operator_reports_status CHECK (status IN ('open', 'resolved', 'dismissed')),
++ CONSTRAINT vision_operator_reports_owner CHECK (owner_subject IS NULL OR owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_operator_reports_object_type CHECK (length(object_type) BETWEEN 1 AND 80),
++ CONSTRAINT vision_operator_reports_object_id CHECK (object_id IS NULL OR length(object_id) <= 300)
++);
++CREATE INDEX vision_operator_reports_open ON vision_operator_reports (status, created_at DESC);
ddiff --git a/migrations/0008_review_reveals.sql b/migrations/0008_review_reveals.sql
nnew file mode 100644
iindex 0000000..5e354e4
---- /dev/null
++++ b/migrations/0008_review_reveals.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- VIS-6 operator evidence views: when an operator presses "Show the picture" on a hidden or quarantined
++-- review, Vision asks Media for a 60-second operator view and records who saw it and when. The row is
++-- shown on the review page and stays with the operator report; it records no image and no signed URL.
++CREATE TABLE vision_review_reveals (
++ id text COLLATE "C" PRIMARY KEY, -- virv_ + ULID
++ review_id text COLLATE "C" NOT NULL REFERENCES vision_image_reviews (id) ON DELETE CASCADE,
++ operator_subject text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL,
++ expires_at timestamptz,
++ revealed_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT vision_review_reveals_id CHECK (id ~ '^virv_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_review_reveals_operator CHECK (operator_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT vision_review_reveals_kind CHECK (kind IN ('media_operator_view', 'preview'))
++);
++CREATE INDEX vision_review_reveals_by_review ON vision_review_reveals (review_id, revealed_at DESC);
ddiff --git a/package-lock.json b/package-lock.json
iindex b8bfc5b..1030071 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"onnxruntime-node": "^1.31.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.114.0",
"pg": "^8.23.0",
@@@ -2625,9 +2625,9 @@
"license": "MIT"
},
"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 ce3988d..88140f9 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"onnxruntime-node": "^1.31.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.114.0",
"pg": "^8.23.0",
ddiff --git a/public/css/vision.css b/public/css/vision.css
iindex 5afd0d8..f6adbc8 100644
---- a/public/css/vision.css
++++ b/public/css/vision.css
@@@ -369,3 +369,80 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
/* On a phone the room's picture is a faint corner decoration (room.css); the scene is content, so it joins the flow
under the hero's buttons, full strength. */
@media (max-width: 759px) { .sc-room-pic:has(.vi-scene) { position: static; z-index: auto; width: auto; opacity: 1; pointer-events: auto; margin-top: 8px; } }
++
++/* VIS-6: the operator's unsafe-image review. Exactly three categories, plain words, no sideways scroll. */
++.vi-review { padding-top: clamp(20px, 4vw, 40px); }
++.vi-review-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 12px; }
++.vi-review-tab { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); color: inherit; text-decoration: none; font-size: .86rem; font-weight: 700; }
++.vi-review-tab span { padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-variant-numeric: tabular-nums; }
++.vi-review-tab.is-on { border-color: var(--room-accent); background: color-mix(in srgb, var(--room-accent) 14%, var(--bg-card, #0f1729)); }
++.vi-review-tab:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
++.vi-review-rules { margin: 0 0 16px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); color: var(--text-secondary, #96a7c2); font-size: .86rem; line-height: 1.5; }
++.vi-review-rules b { color: var(--text-primary, #e6edf7); }
++.vi-review-list, .vi-report-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.vi-review-row { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 12px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
++.vi-review-row.is-sex_acts { border-left: 4px solid var(--warning, #f59e0b); }
++.vi-review-row.is-minor_sexual { border-left: 4px solid var(--error, #ef4444); }
++.vi-review-row.is-fine { border-left: 4px solid var(--success, #22c55e); }
++.vi-review-pic { display: block; width: 96px; height: 72px; border-radius: 12px; overflow: hidden; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); }
++.vi-review-pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.vi-review-main { min-width: 0; }
++.vi-review-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
++.vi-review-badge { display: inline-flex; align-items: center; min-height: 26px; padding: 2px 9px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; }
++.vi-review-badge.is-fine { background: color-mix(in srgb, var(--success, #22c55e) 18%, transparent); color: color-mix(in srgb, var(--success, #22c55e) 70%, var(--text-primary, #e6edf7)); }
++.vi-review-badge.is-sex_acts { background: color-mix(in srgb, var(--warning, #f59e0b) 18%, transparent); color: color-mix(in srgb, var(--warning, #f59e0b) 70%, var(--text-primary, #e6edf7)); }
++.vi-review-badge.is-minor_sexual { background: color-mix(in srgb, var(--error, #ef4444) 18%, transparent); color: color-mix(in srgb, var(--error, #ef4444) 70%, var(--text-primary, #e6edf7)); }
++.vi-review-plain { color: var(--text-secondary, #96a7c2); font-size: .82rem; }
++.vi-review-obj, .vi-review-reason, .vi-review-meta { margin: 5px 0 0; font-size: .875rem; line-height: 1.45; overflow-wrap: anywhere; }
++.vi-review-obj { color: var(--text-secondary, #96a7c2); }
++.vi-review-obj code, .vi-review-meta code { font-size: .82em; }
++.vi-review-reason { color: var(--text-primary, #e6edf7); }
++.vi-review-meta { color: var(--text-muted, #7386a3); font-size: .78rem; }
++.vi-review-row > .vi-btn { justify-self: end; }
++.vi-review-reports { margin-top: 26px; }
++.vi-report-row { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); font-size: .84rem; line-height: 1.5; overflow-wrap: anywhere; }
++.vi-report-row a { margin-left: 6px; }
++
++/* One review */
++.vi-review-detail { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 22px; align-items: start; }
++.vi-review-figure { margin: 0; display: grid; gap: 8px; }
++.vi-review-figure img { width: 100%; height: auto; max-height: 62vh; object-fit: contain; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
++.vi-review-figure figcaption { color: var(--text-muted, #7386a3); font-size: .78rem; line-height: 1.5; }
++.vi-review-detail-main { min-width: 0; display: grid; gap: 12px; }
++.vi-review-detail-main h2 { font-size: 1.05rem; }
++.vi-review-report { padding: 14px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--error, #ef4444) 45%, var(--border, #1f2d47)); background: color-mix(in srgb, var(--error, #ef4444) 8%, var(--bg-card, #0f1729)); }
++.vi-review-report p { margin: 6px 0 0; font-size: .86rem; line-height: 1.5; overflow-wrap: anywhere; }
++.vi-review-form { display: grid; gap: 12px; margin-top: 4px; }
++.vi-review-choices { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; }
++.vi-review-choices legend { margin-bottom: 8px; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.vi-review-choice { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); cursor: pointer; }
++.vi-review-choice.is-on { border-color: var(--room-accent); background: color-mix(in srgb, var(--room-accent) 9%, var(--bg-card, #0f1729)); }
++.vi-review-choice input { margin-top: 4px; width: 20px; height: 20px; accent-color: var(--room-accent); }
++.vi-review-choice b { display: block; font-size: .95rem; }
++.vi-review-choice small { display: block; margin-top: 3px; color: var(--text-secondary, #96a7c2); font-size: .82rem; line-height: 1.45; }
++.vi-review-choice:focus-within { outline: 2px solid var(--room-accent); outline-offset: 2px; }
++
++@media (max-width: 760px) {
++ .vi-review-row { grid-template-columns: 72px minmax(0, 1fr); }
++ .vi-review-pic { width: 72px; height: 56px; }
++ .vi-review-row > .vi-btn { grid-column: 1 / -1; justify-self: start; }
++ .vi-review-detail { grid-template-columns: minmax(0, 1fr); }
++ .vi-review-tab { flex: 1 1 auto; justify-content: center; }
++}
++
++/* VIS-6 evidence handling: category 2 and 3 pictures stay hidden or blurred until an operator reveals one. */
++.vi-review-thumb.is-blurred { filter: blur(12px); transform: scale(1.04); }
++.vi-review-noimage { display: flex; align-items: center; justify-content: center; width: 96px; height: 72px; padding: 6px; border-radius: 12px; border: 1px dashed var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: var(--text-muted, #7386a3); font-size: .68rem; font-weight: 800; text-align: center; line-height: 1.25; text-transform: uppercase; letter-spacing: .04em; }
++.vi-review-evidence.is-blurred { filter: blur(22px); transform: scale(1.03); }
++.vi-review-held { display: flex; align-items: center; justify-content: center; min-height: 260px; padding: 24px; border-radius: 18px; border: 1px dashed color-mix(in srgb, var(--error, #ef4444) 45%, var(--border, #1f2d47)); background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--error, #ef4444) 7%, var(--bg-card, #0f1729)) 0 12px, var(--bg-card, #0f1729) 12px 24px); color: var(--text-secondary, #96a7c2); font-weight: 700; text-align: center; }
++.vi-review-evidence-note { display: grid; gap: 8px; padding: 14px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 45%, var(--border, #1f2d47)); background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-card, #0f1729)); }
++.vi-review-evidence-note h2 { font-size: 1rem; }
++.vi-review-evidence-note p { margin: 0; font-size: .86rem; line-height: 1.5; }
++.vi-review-evidence-note form { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
++.vi-review-reveals { display: grid; gap: 6px; padding: 14px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
++.vi-review-reveals h2 { font-size: 1rem; }
++.vi-review-reveals ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.vi-review-reveals li { font-size: .82rem; line-height: 1.45; overflow-wrap: anywhere; }
++@media (max-width: 760px) {
++ .vi-review-noimage { width: 72px; height: 56px; font-size: .62rem; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 51f9957..9c6daf8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,7 +4,7 @@
* OpenVibe.Vision — Express app factory; server/index.js listens, tests build their own instance with a temp
* database, a mock Network and a mock OpenVibe.AI.
*
-- * /, /ask, /ocr, /compare, /ui-check, /a/:id, /ui/:id, /people, /people/privacy, /developers the pages (http/pages.js)
++ * /, /ask, /ocr, /compare, /ui-check, /a/:id, /ui/:id, /people, /people/privacy, /review, /developers the pages (http/pages.js)
* /api/v1/* the API (http/api.js, http/faces-api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
@@@ -40,6 +40,8 @@ const { createStore } = require('./store');
const { createOcr } = require('./vision/ocr');
const { createAi } = require('./vision/ai');
const { createVision } = require('./vision/service');
++const { createReview } = require('./vision/review');
++const { createMediaView } = require('./vision/media-view');
const { createFacesModel } = require('./vision/faces-model');
const { createFaces } = require('./vision/faces');
const { createFaceLibraries } = require('./vision/face-libraries');
@@@ -71,6 +73,8 @@ async function createApp(opts = {}) {
const ocr = opts.ocr || createOcr({ config, log });
const ai = opts.ai || createAi({ config, fetchImpl, log });
const vision = createVision({ config, s, ocr, ai, store, log });
++ const mediaView = opts.mediaView || createMediaView({ config, fetchImpl, log });
++ const review = opts.review || createReview({ config, s, ai, store, mediaView, log });
const facesModel = opts.facesModel || createFacesModel({ dir: config.faces.modelsDir });
const faces = createFaces({ s, config, model: facesModel });
const consentRegistry = createConsentRegistry({ config, fetchImpl, log });
@@@ -89,7 +93,7 @@ async function createApp(opts = {}) {
// until OpenVibe.Events takes them, so a failed publish is not lost. The relay starts in server/index.js.
const eventOutbox = opts.eventOutbox || createVisionOutbox({ s, config, log, fetchImpl });
const eventPublisher = opts.eventPublisher || createPublisher({ outbox: eventOutbox, log });
-- const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, faceLibraries, consentRegistry, models, networkTools, accountData, accountSend, eventOutbox, eventPublisher, log, fetchImpl };
++ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, review, faces, faceLibraries, consentRegistry, models, networkTools, accountData, accountSend, eventOutbox, eventPublisher, log, fetchImpl };
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ab9b743..82f429d 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -23,9 +23,11 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { loadImage } = require('../vision/images');
const { VisionError } = require('../vision/service');
++const { CATEGORIES, validPreviewUrl } = require('../vision/review');
++const { isStaff } = require('../identity/staff');
function createApi(ctx) {
-- const { config, s, principal, store, vision, ai, ocr, fetchImpl, networkTools, models: localModels, eventPublisher } = ctx;
++ const { config, s, principal, store, vision, review, ai, ocr, fetchImpl, networkTools, models: localModels, eventPublisher } = ctx;
const r = asyncRouter();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
@@@ -253,6 +255,98 @@ function createApi(ctx) {
return res.json(row);
});
++ // ── Unsafe-image review (VIS-6) ────────────────────────
++ // Exactly three categories, plain words: fine (nudity included), sex_acts (never public, kept for its
++ // owner), minor_sexual (always blocked, quarantined, and an operator report row made). A service token
++ // needs vision.image.review; a person must be staff. The operator's decision route takes exactly one of
++ // the same three, so an old or invented label can never be stored.
++ const staffPerson = (req, res, next) => {
++ const p = req.principal;
++ if (!p || p.kind === 'anonymous') return problem(res, 401, 'token.required', 'Sign in at openvibe.vision.');
++ return p.kind === 'user' && isStaff(p) ? next() : problem(res, 403, 'vision.staff_only', 'Only OpenVibe staff can open an operator view.');
++ };
++ const staffOr = (cap, what) => (req, res, next) => {
++ const p = req.principal;
++ if (!p || p.kind === 'anonymous') return problem(res, 401, 'token.required', `Sign in at openvibe.vision, or send a Network token holding ${cap}.`);
++ if (p.kind === 'user') return isStaff(p) ? next() : problem(res, 403, 'vision.staff_only', `Only OpenVibe staff can ${what}.`);
++ return principal.granted(p, cap) ? next() : problem(res, 403, 'capability.denied', `${cap} not granted`);
++ };
++ const reviewCategory = (v) => (CATEGORIES.includes(String(v || '')) ? String(v) : null);
++
++ r.post('/review', principal.optional('vision.image.review'), staffOr('vision.image.review', 'submit a review'), attachUpload, (req, res) => run(res, async () => {
++ const b = bodyOf(req);
++ const text = b.text == null ? null : String(b.text).trim().slice(0, 4000);
++ if (!req.file && !b.image && !b.image_url && !text) return invalid(res, 'send an image, an image_url, a data URL in image, or text');
++ const objectType = String(b.object_type || 'unknown').trim().slice(0, 80) || 'unknown';
++ const objectId = b.object_id == null ? null : String(b.object_id).trim().slice(0, 300) || null;
++ const objectApp = b.object_app == null ? null : String(b.object_app).trim().slice(0, 80) || null;
++ const mediaObjectId = b.media_object_id == null ? null : String(b.media_object_id).trim().slice(0, 300) || null;
++ const mediaApp = b.media_app == null ? null : String(b.media_app).trim().slice(0, 80) || null;
++ if (mediaObjectId && !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(mediaObjectId)) return invalid(res, 'media_object_id must be a Media object id (med_…)');
++ let ownerSubject = b.owner_subject == null ? null : String(b.owner_subject).trim().replace(/^user:/, '');
++ if (ownerSubject && !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(ownerSubject)) return invalid(res, 'owner_subject must be a user subject (usr_…)');
++ if (b.preview_url != null && b.preview_url !== '' && !validPreviewUrl(b.preview_url)) {
++ return problem(res, 400, 'vision.invalid_preview_url', 'preview_url must be an https URL on openvibe.vision, openvibe.network or openvibe.media');
++ }
++ const previewUrl = b.preview_url ? validPreviewUrl(b.preview_url) : null;
++ const image = req.file || b.image || b.image_url ? await imageOf(req) : null;
++ const out = await review.review({
++ requester: req.principal.requester || 'anonymous',
++ subject: isPerson(req) ? req.principal.subject : null,
++ image, text, objectType, objectId, objectApp, ownerSubject, previewUrl, mediaObjectId, mediaApp,
++ note: b.note == null ? null : String(b.note).slice(0, 300),
++ });
++ return { review: out, category: out.category, plain_words: out.plain_words, action: out.action, public: out.public, status: out.status, report: out.report };
++ }, 201));
++
++ r.get('/review/reports', staffOr('vision.review.read', 'read the operator report queue'), async (req, res) => {
++ const status = req.query.status == null ? 'open' : String(req.query.status);
++ if (!['open', 'resolved', 'dismissed', 'all'].includes(status)) return invalid(res, 'status must be open, resolved, dismissed or all');
++ const reports = await store.listOperatorReports({ status: status === 'all' ? null : status, limit: req.query.limit });
++ res.set('Cache-Control', 'private, no-store');
++ return res.json({ reports });
++ });
++
++ r.get('/review/:id', principal.optional('vision.review.read'), async (req, res) => {
++ const row = await store.reviewWithReport(String(req.params.id || ''));
++ if (!row) return problem(res, 404, 'vision.review_not_found', 'No such review.');
++ const mine = isPerson(req) && row.owner_subject && req.principal.subject === row.owner_subject;
++ if (!mine && !isStaff(req.principal) && !isMachine(req)) return problem(res, 404, 'vision.review_not_found', 'No such review.');
++ res.set('Cache-Control', 'private, no-store');
++ return res.json({ review: row, category: row.category, plain_words: row.plain_words, action: row.action, public: row.public, status: row.status, report: row.report });
++ });
++
++ r.get('/review', staffOr('vision.review.read', 'read the review queue'), async (req, res) => {
++ const category = req.query.category == null || req.query.category === '' ? null : String(req.query.category);
++ if (category && !reviewCategory(category)) return invalid(res, `category must be one of ${CATEGORIES.join(', ')}`);
++ const status = req.query.status == null || req.query.status === '' ? null : String(req.query.status);
++ if (status && !['auto', 'decided'].includes(status)) return invalid(res, 'status must be auto or decided');
++ const reviews = await store.listReviews({ category, status, limit: req.query.limit });
++ res.set('Cache-Control', 'private, no-store');
++ return res.json({ count: reviews.length, counts: await store.reviewCounts(), reviews });
++ });
++
++ r.post('/review/:id/decide', staffOr('vision.review.decide', 'decide a review'), async (req, res) => {
++ const category = reviewCategory(bodyOf(req).category);
++ if (!category) return problem(res, 400, 'vision.review_invalid_category', `category must be one of ${CATEGORIES.join(', ')}`);
++ const by = req.principal.kind === 'user' ? req.principal.subject : req.principal.sub;
++ const out = await review.decide(String(req.params.id || ''), category, by);
++ return res.json({ review: out, category: out.category, plain_words: out.plain_words, action: out.action, public: out.public, status: out.status, report: out.report });
++ });
++
++ // The operator's one-picture evidence view. Staff only, and only for a hidden or quarantined review;
++ // the Media call is logged by Media and the reveal row by Vision.
++ r.post('/review/:id/reveal', staffPerson, async (req, res) => {
++ try {
++ const out = await review.reveal(String(req.params.id || ''), req.principal.subject);
++ res.set('Cache-Control', 'private, no-store');
++ return res.json({ view: out });
++ } catch (err) {
++ if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail);
++ throw err;
++ }
++ });
++
// ── Model hints and benchmarks (no subject data) ───────
let modelCache = null;
r.get('/models', principal.optional('vision.models.read', { allowAnonymous: true }), async (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 33bf1ee..8cc58ce 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -15,6 +15,7 @@
* /find, /read, /watch the network tools (private search, receipts, camera)
* /people the four face consents, each with its own signed switch
* /people/privacy face retention, liveness and what never happens
++ * /review, /review/:id, /review/reports the unsafe-image review (staff only)
* /developers the API
* 404
*/
@@@ -29,11 +30,12 @@ const { sameOrigin } = require('./principal');
const { loadImage } = require('../vision/images');
const { check: uiCheck } = require('../vision/ui-check');
const { VisionError } = require('../vision/service');
--const { html, raw } = require('../render/html');
++const { html, raw, esc } = require('../render/html');
const { send } = require('../render/layout');
const { isStaff } = require('../identity/staff');
const views = require('../views');
const peopleViews = require('../views/people');
++const reviewViews = require('../views/review');
const toolViews = require('../views/tools');
const PUBLIC_CACHE = 'public, max-age=60';
@@@ -41,7 +43,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const SAMPLE_IMAGES = { receipt: 'sample-receipt.png', sign: 'sample-sign.png' };
function createPageRoutes(ctx) {
-- const { config, s, store, vision, ai, ocr, faces, networkTools, fetchImpl } = ctx;
++ const { config, s, store, vision, review, ai, ocr, faces, networkTools, fetchImpl } = ctx;
const r = asyncRouter();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 2 } });
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
@@@ -363,6 +365,76 @@ function createPageRoutes(ctx) {
return page(req, res, { title: 'A UI check', body: raw(views.uiCheckResult({ c: row, mine })), cache: null });
});
++ // ── Unsafe-image review (VIS-6, staff) ───────────────────
++ // Exactly three categories. The queue and the decision form are staff-only; the API is where a service
++ // sends an image and where the same three categories are enforced in code.
++ const staffPage = (req, res) => {
++ if (!signedIn(req)) {
++ page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'Review', text: 'The unsafe-image review queue is for OpenVibe staff.', next: '/review' })) }, 401);
++ return false;
++ }
++ if (!staffViewer(req)) {
++ page(req, res, { title: 'Staff only', body: raw(html`<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty"><h1>Staff only</h1><p>The unsafe-image review queue is for OpenVibe staff. Your role is <code>${esc(req.viewer.role || 'user')}</code>.</p><a class="vi-btn" href="/">Back to Vision</a></div></div>`) }, 403);
++ return false;
++ }
++ return true;
++ };
++ const reviewCategory = (v) => (reviewViews.PLAIN[String(v || '')] ? String(v) : null);
++ const reviewProblem = (err) => (err instanceof VisionError ? err.detail : 'That review could not be saved.');
++
++ r.get('/review', async (req, res) => {
++ if (!staffPage(req, res)) return;
++ const category = reviewCategory(req.query.category);
++ const [reviews, counts, reports] = await Promise.all([
++ store.listReviews({ category, limit: 100 }),
++ store.reviewCounts(),
++ store.listOperatorReports({ status: 'open', limit: 10 }),
++ ]);
++ return page(req, res, {
++ title: 'Unsafe-image review', cache: null,
++ body: raw(reviewViews.queue({ reviews, counts, category, reports, saved: req.query.saved === '1' })),
++ });
++ });
++ r.get('/review/reports', async (req, res) => {
++ if (!staffPage(req, res)) return;
++ const status = ['open', 'resolved', 'dismissed', 'all'].includes(String(req.query.status || '')) ? String(req.query.status) : 'open';
++ const reports = await store.listOperatorReports({ status: status === 'all' ? null : status, limit: 200 });
++ return page(req, res, { title: 'Operator reports', cache: null, body: raw(reviewViews.reports({ reports })) });
++ });
++ r.get('/review/:id', async (req, res) => {
++ if (!staffPage(req, res)) return;
++ const row = await store.reviewWithReport(String(req.params.id || ''));
++ if (!row) return notFound(req, res);
++ return page(req, res, { title: 'Review one image', cache: null, body: raw(reviewViews.item({ review: row, saved: req.query.saved === '1' })) });
++ });
++ r.post('/review/:id/decide', async (req, res) => {
++ if (!staffPage(req, res)) return;
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const category = reviewCategory(bodyOf(req).category);
++ try {
++ if (!category) throw new VisionError(400, 'vision.review_invalid_category', 'Choose one of the three categories.');
++ await review.decide(String(req.params.id || ''), category, req.viewer.subject);
++ return res.redirect(303, `/review/${encodeURIComponent(String(req.params.id))}?saved=1`);
++ } catch (err) {
++ const row = await store.reviewWithReport(String(req.params.id || ''));
++ return page(req, res, { title: 'Review one image', cache: null, body: raw(reviewViews.item({ review: row, problem: reviewProblem(err) })) }, err instanceof VisionError ? err.status : 400);
++ }
++ });
++
++ r.post('/review/:id/reveal', async (req, res) => {
++ if (!staffPage(req, res)) return;
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const row = await store.reviewWithReport(String(req.params.id || ''));
++ if (!row) return notFound(req, res);
++ try {
++ const revealed = await review.reveal(row.id, req.viewer.subject);
++ const updated = await store.reviewWithReport(row.id);
++ return page(req, res, { title: 'Review one image', cache: null, body: raw(reviewViews.item({ review: updated || row, revealed })) });
++ } catch (err) {
++ return page(req, res, { title: 'Review one image', cache: null, body: raw(reviewViews.item({ review: row, problem: reviewProblem(err) })) }, err instanceof VisionError ? err.status : 502);
++ }
++ });
++
// ── Developers ───────────────────────────────────────────
r.get('/developers', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Vision API',
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex ff4b288..34e827b 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -29,6 +29,7 @@ const CAPABILITIES = Object.freeze([
'vision.face.consent.manage', 'vision.face.verify.self', 'vision.face.find.self',
'vision.face.library', 'vision.face.cast.match', 'vision.face.cast.read',
'vision.models.read', 'vision.benchmarks.read',
++ 'vision.image.review', 'vision.review.read', 'vision.review.decide',
]);
function decodePayload(token) {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 7810f54..eec9c88 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -22,6 +22,9 @@ const TABLES = [
{ table: 'face_media', subject: 'subject', value: (usr) => usr, file: 'face-media.json', erase: 'delete' },
{ table: 'face_consents', subject: 'subject', value: (usr) => usr, file: 'face-consents.json', erase: 'delete' },
{ table: 'vision_private_index', subject: 'owner', value: (usr) => usr, file: 'private-index.json', erase: 'delete' },
++ // The review and its operator report (VIS-6). The report goes first: it references the review row.
++ { table: 'vision_operator_reports', subject: 'owner_subject', value: (usr) => usr, file: 'operator-reports.json', erase: 'delete' },
++ { table: 'vision_image_reviews', subject: 'owner_subject', value: (usr) => usr, file: 'image-reviews.json', erase: 'delete' },
// Compare samples before their answer, so the deletion counts them itself rather than through the FK cascade.
{ table: 'vision_compare_samples', subject: 'subject', value: (usr) => usr, file: 'compare.json', erase: 'delete' },
{ table: 'vision_answers', subject: 'subject', value: (usr) => usr, file: 'answers.json', erase: 'delete' },
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4fb8144..726a973 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -13,6 +13,7 @@ const rooms = require('openvibe-shared/rooms');
const ovServe = require('openvibe-shared/serve');
const cache = require('openvibe-shared/cache-policy');
const { render, esc } = require('./html');
++const { isStaff } = require('../identity/staff');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Vision';
@@@ -50,10 +51,13 @@ function renderPage(o) {
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
++ const links = NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) }));
++ // The operator's review queue is not a public tool: only staff see the link (the route itself is staff-only).
++ if (isStaff(viewer)) links.push({ label: 'Review', href: '/review', active: pathNow === '/review' || pathNow.startsWith('/review/') });
const nav = {
service: 'vision',
apiBase: NETWORK_URL,
-- links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ links,
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
@@@ -80,7 +84,7 @@ function renderPage(o) {
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
-- navLinks: NAV,
++ navLinks: links,
navbar: nav,
footer,
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
ddiff --git a/server/store.js b/server/store.js
iindex 3f8fd69..b43c72c 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -8,6 +8,8 @@
* GET /api/v1/benchmarks aggregates these rows without subjects: per provider/model, task, samples, successful
* answers, mean latency and mean cost. v1 keeps the measurements; the public Arena leaderboards are v2.
*/
++const { wireReview, wireReport, wireReveal } = require('./vision/review');
++
const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
@@@ -183,7 +185,115 @@ function createStore(s) {
}));
}
-- return { saveAnswer, answerById, answerSubject, listAnswers, saveCompare, saveUiCheck, uiCheckById, uiCheckSubject, benchmarks };
++ // ── Unsafe-image reviews (VIS-6) ─────────────────────────
++ async function saveReview({ subject = null, requester, objectType = 'unknown', objectId = null, objectApp = null, ownerSubject = null, mediaObjectId = null, mediaApp = null,
++ imageSha256 = null, imageMime = null, imageBytes = null, previewUrl = null, category, plainWords, action,
++ public: isPublic = false, reason = null, cue = null, engine, model = null, provider = null, route = null,
++ confidence = null, costUsd = 0, latencyMs = 0 }) {
++ const id = s.newId('vir');
++ const row = await db.maybe(
++ `INSERT INTO vision_image_reviews (id, subject, requester, object_type, object_id, object_app, owner_subject, image_sha256,
++ image_mime, image_bytes, preview_url, category, plain_words, action, is_public, reason, cue, engine, model,
++ provider, route, confidence, cost_usd, latency_ms, media_object_id, media_app, created_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,now()) RETURNING *`,
++ [id, subject, requester, String(objectType).slice(0, 80), objectId == null ? null : String(objectId).slice(0, 300), objectApp,
++ ownerSubject, imageSha256, imageMime, imageBytes, previewUrl, category, plainWords, action, Boolean(isPublic), reason,
++ cue, engine, model, provider, route, confidence == null ? null : Math.max(0, Math.min(1, Number(confidence) || 0)),
++ Math.max(0, Number(costUsd) || 0), Math.max(0, Math.round(Number(latencyMs) || 0)), mediaObjectId, mediaApp],
++ );
++ return wireReview(row);
++ }
++
++ async function reviewById(id) {
++ return wireReview(await db.maybe('SELECT * FROM vision_image_reviews WHERE id = $1', [String(id || '')]));
++ }
++
++ async function reviewWithReport(id) {
++ const row = await db.maybe('SELECT * FROM vision_image_reviews WHERE id = $1', [String(id || '')]);
++ if (!row) return null;
++ const report = await operatorReportFor(row.id);
++ const reveals = await listReviewReveals(row.id);
++ return wireReview({ ...row, report, reveals });
++ }
++
++ async function listReviews({ category = null, status = null, limit = 50 } = {}) {
++ const n = Math.min(200, Math.max(1, Number(limit) || 50));
++ const args = []; const where = [];
++ if (category) { args.push(String(category)); where.push(`category = $${args.length}`); }
++ if (status) { args.push(String(status)); where.push(`status = $${args.length}`); }
++ args.push(n);
++ const rows = await db.many(`SELECT * FROM vision_image_reviews ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT $${args.length}`, args);
++ const reportIds = rows.filter((r) => r.category === 'minor_sexual').map((r) => r.id);
++ const reports = reportIds.length ? await db.many('SELECT * FROM vision_operator_reports WHERE review_id = ANY($1::text[])', [reportIds]) : [];
++ const byReview = new Map(reports.map((r) => [r.review_id, r]));
++ return rows.map((r) => wireReview({ ...r, report: byReview.get(r.id) || null }));
++ }
++
++ async function reviewCounts() {
++ const rows = await db.many('SELECT category, COUNT(*)::int AS n FROM vision_image_reviews GROUP BY category');
++ const out = { fine: 0, sex_acts: 0, minor_sexual: 0, total: 0 };
++ for (const r of rows) { if (out[r.category] != null) out[r.category] = Number(r.n) || 0; out.total += Number(r.n) || 0; }
++ const open = await db.maybe("SELECT COUNT(*)::int AS n FROM vision_operator_reports WHERE status = 'open'");
++ out.reports_open = Number(open && open.n) || 0;
++ return out;
++ }
++
++ async function decideReview(id, { category, plainWords, action, public: isPublic, reviewedBy }) {
++ return wireReview(await db.maybe(
++ `UPDATE vision_image_reviews SET category = $2, plain_words = $3, action = $4, is_public = $5,
++ status = 'decided', reviewed_by = $6, reviewed_at = now() WHERE id = $1 RETURNING *`,
++ [String(id), category, plainWords, action, Boolean(isPublic), reviewedBy],
++ ));
++ }
++
++ async function createOperatorReport({ reviewId, category = 'minor_sexual', objectType = 'unknown', objectId = null, ownerSubject = null, reason = null }) {
++ const id = s.newId('vor');
++ const row = await db.maybe(
++ `INSERT INTO vision_operator_reports (id, review_id, category, object_type, object_id, owner_subject, reason, created_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,now()) ON CONFLICT (review_id) DO NOTHING RETURNING *`,
++ [id, String(reviewId), category, String(objectType).slice(0, 80), objectId == null ? null : String(objectId).slice(0, 300), ownerSubject, reason],
++ );
++ return wireReport(row || await db.maybe('SELECT * FROM vision_operator_reports WHERE review_id = $1', [String(reviewId)]));
++ }
++
++ async function operatorReportFor(reviewId) {
++ return wireReport(await db.maybe('SELECT * FROM vision_operator_reports WHERE review_id = $1', [String(reviewId || '')]));
++ }
++
++ async function listOperatorReports({ status = 'open', limit = 50 } = {}) {
++ const n = Math.min(200, Math.max(1, Number(limit) || 50));
++ const args = []; const where = [];
++ if (status) { args.push(String(status)); where.push(`r.status = $${args.length}`); }
++ args.push(n);
++ const rows = await db.many(`SELECT r.* FROM vision_operator_reports r ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY r.created_at DESC, r.id DESC LIMIT $${args.length}`, args);
++ return rows.map(wireReport);
++ }
++
++ async function saveReviewReveal({ reviewId, operatorSubject, kind, expiresAt = null }) {
++ const id = s.newId('virv');
++ return wireReveal(await db.maybe(
++ `INSERT INTO vision_review_reveals (id, review_id, operator_subject, kind, expires_at, revealed_at)
++ VALUES ($1,$2,$3,$4,$5::timestamptz,now()) RETURNING *`,
++ [id, String(reviewId), operatorSubject, kind, expiresAt],
++ ));
++ }
++
++ async function listReviewReveals(reviewId) {
++ const rows = await db.many('SELECT * FROM vision_review_reveals WHERE review_id = $1 ORDER BY revealed_at DESC, id DESC LIMIT 50', [String(reviewId || '')]);
++ return rows.map(wireReveal);
++ }
++
++ async function dismissOperatorReport(reviewId, by) {
++ return wireReport(await db.maybe(
++ `UPDATE vision_operator_reports SET status = 'dismissed', resolved_at = now(), resolved_by = $2
++ WHERE review_id = $1 AND status = 'open' RETURNING *`,
++ [String(reviewId), String(by || 'operator')],
++ ));
++ }
++
++ return { saveAnswer, answerById, answerSubject, listAnswers, saveCompare, saveUiCheck, uiCheckById, uiCheckSubject, benchmarks,
++ saveReview, reviewById, reviewWithReport, listReviews, reviewCounts, decideReview, createOperatorReport,
++ operatorReportFor, listOperatorReports, dismissOperatorReport, saveReviewReveal, listReviewReveals };
}
module.exports = { createStore, wireAnswer, wireSample, wireUiCheck };
ddiff --git a/server/views/review.js b/server/views/review.js
nnew file mode 100644
iindex 0000000..9197f6b
---- /dev/null
++++ b/server/views/review.js
@@@ -0,0 +1,137 @@
++'use strict';
++
++/**
++ * The operator's unsafe-image review (VIS-6). One page lists the queue, one page shows a review and its
++ * decision form. Everything is server-rendered and works without JavaScript. The three categories are the
++ * only choices, written in plain words: fine (nudity included), sex acts or porn (never public), and sexual
++ * content involving a minor (always blocked and reported to the operator).
++ *
++ * Category 2 and 3 pictures are not shown by default. A sex-act thumbnail is blurred in the queue; a
++ * category-3 row has no picture at all. The detail page asks Media for a 60-second operator view only when
++ * staff press Show, and every reveal is logged on the report. A category-3 picture must never be saved or
++ * shared; it is evidence for the report.
++ */
++const { esc, time } = require('../render/html');
++
++const PLAIN = {
++ fine: 'Fine — OK to show, including nudity',
++ sex_acts: 'Sex acts or porn — never public; kept for its owner',
++ minor_sexual: 'Sexual content involving a minor — always blocked and reported to the operator',
++};
++const SHORT = { fine: 'Fine', sex_acts: 'Sex acts or porn', minor_sexual: 'Sexual content involving a minor' };
++const ACTION = { fine: 'Nothing changes; it may be shown.', sex_acts: 'Hide it from every public surface; its owner keeps it.', minor_sexual: 'Block it everywhere, quarantine the object, and keep a report for the operator.' };
++
++const preview = (r) => (r.preview_url ? String(r.preview_url) : null);
++const mediaRef = (r) => (r.media_object_id || (r.object_type === 'media.object' ? r.object_id : null));
++const evidenceWarn = 'This picture is evidence for the report: do not save it, copy it or share it. Vision keeps it only for the operator\'s review.';
++
++function badge(category) {
++ return `<span class="vi-review-badge is-${esc(category)}">${esc(SHORT[category] || category)}</span>`;
++}
++
++function queuePicture(r) {
++ if (r.category === 'minor_sexual') return '<span class="vi-review-noimage">Picture withheld</span>';
++ if (!preview(r)) return '<span class="vi-review-noimage">No preview</span>';
++ return `<img class="vi-review-thumb${r.category === 'sex_acts' ? ' is-blurred' : ''}" src="${esc(preview(r))}" alt="" width="96" height="72" loading="lazy">`;
++}
++
++function evidenceFigure(r, revealed = null) {
++ if (revealed) {
++ return `<figure class="vi-review-figure"><img class="vi-review-evidence" src="${esc(revealed.url)}" referrerpolicy="no-referrer" alt="The picture under review, shown for this operator view"><figcaption>Shown for a 60-second operator view; this reveal is logged. ${esc(evidenceWarn)}</figcaption></figure>`;
++ }
++ if (r.category === 'fine' && preview(r)) {
++ return `<figure class="vi-review-figure"><img class="vi-review-evidence" src="${esc(preview(r))}" alt="The picture under review"><figcaption>${r.preview_url ? `<a href="${esc(preview(r))}" rel="noopener noreferrer">Original preview</a>` : ''} The bytes are not stored by Vision.</figcaption></figure>`;
++ }
++ if (r.category === 'sex_acts' && preview(r)) {
++ return `<figure class="vi-review-figure"><img class="vi-review-evidence is-blurred" src="${esc(preview(r))}" alt="The picture under review, blurred"><figcaption>Blurred until you choose Show. ${esc(evidenceWarn)}</figcaption></figure>`;
++ }
++ return `<figure class="vi-review-figure"><div class="vi-review-held" role="img" aria-label="Picture withheld">Picture withheld${r.category === 'minor_sexual' ? ' — quarantined as evidence' : ''}</div><figcaption>${r.category === 'minor_sexual' ? 'Media refuses even a signed read. Vision asks Media for a 60-second view only when you press Show.' : 'The picture is hidden from the public.'} ${esc(evidenceWarn)}</figcaption></figure>`;
++}
++
++function revealLog(r) {
++ const rows = Array.isArray(r.reveals) ? r.reveals : [];
++ if (!rows.length) return '';
++ return `<section class="vi-review-reveals" aria-labelledby="vi-reveals-h"><h2 id="vi-reveals-h">Operator views</h2><ul>${rows.map((x) => `<li><b>${esc(x.operator_subject)}</b> · ${esc(x.kind === 'media_operator_view' ? 'Media operator view' : 'preview link')} · ${time(x.revealed_at)}${x.expires_at ? ` · until ${time(x.expires_at)}` : ''}</li>`).join('')}</ul><p class="vi-fine">Every Show is logged here for the report.</p></section>`;
++}
++
++function facts(r) {
++ return `<dl class="vi-facts">
++<div><dt>Category</dt><dd>${esc(SHORT[r.category] || r.category)}</dd></div>
++<div><dt>Object</dt><dd>${esc(r.object_type)}${r.object_id ? ` <code>${esc(r.object_id)}</code>` : ''}</dd></div>
++<div><dt>Owner</dt><dd>${r.owner_subject ? `<code>${esc(r.owner_subject)}</code>` : '—'}</dd></div>
++<div><dt>Engine</dt><dd>${esc(r.engine)}${r.model ? ` · ${esc(r.model)}` : ''}</dd></div>
++<div><dt>Time</dt><dd>${time(r.created_at)}</dd></div>
++<div><dt>Image</dt><dd>${r.image_sha256 ? `<code>${esc(String(r.image_sha256).slice(0, 12))}…</code>` : 'not kept'}</dd></div>
++</dl>`;
++}
++
++function queue({ reviews = [], counts = {}, category = null, saved = null, reports = [] } = {}) {
++ const tab = (key, label, n) => `<a class="vi-review-tab${category === key ? ' is-on' : ''}" href="${key ? `/review?category=${key}` : '/review'}">${esc(label)} <span>${Number(n) || 0}</span></a>`;
++ const row = (r) => `<li class="vi-review-row is-${esc(r.category)}" id="vi-review-${esc(r.id)}">
++<a class="vi-review-pic" href="/review/${encodeURIComponent(r.id)}" aria-hidden="true" tabindex="-1">${queuePicture(r)}</a>
++<div class="vi-review-main">
++<div class="vi-review-head">${badge(r.category)}<span class="vi-review-plain">${esc(r.plain_words || PLAIN[r.category])}</span></div>
++<p class="vi-review-obj">${esc(r.object_type)}${r.object_id ? ` · <code>${esc(r.object_id)}</code>` : ''}${r.owner_subject ? ` · owner <code>${esc(r.owner_subject)}</code>` : ''}</p>
++<p class="vi-review-reason">${esc(r.reason || 'No reason was given.')}</p>
++<p class="vi-review-meta">${esc(r.engine)}${r.model ? ` · ${esc(r.model)}` : ''} · ${time(r.created_at)}${r.report ? ` · <b>operator report ${esc(r.report.status)}</b>` : ''}${r.status === 'decided' ? ' · decided by staff' : ''}</p>
++</div>
++<a class="vi-btn vi-btn-small" href="/review/${encodeURIComponent(r.id)}">Review</a>
++</li>`;
++ const reportRow = (r) => `<li class="vi-report-row" id="vi-report-${esc(r.id)}"><b>${esc(SHORT[r.category] || r.category)}</b> <code>${esc(r.id)}</code> · ${esc(r.object_type)}${r.object_id ? ` <code>${esc(r.object_id)}</code>` : ''} · ${esc(r.status)} · ${time(r.created_at)} <a href="/review/${encodeURIComponent(r.review_id)}">Open review</a></li>`;
++ return `<div class="vi-wrap vi-review" id="vi-review">
++<header class="vi-page-h"><p class="vi-kicker">Operator review</p><h1>Unsafe-image review</h1>
++<p class="vi-lede">Every image sorts into exactly three categories. Nudity is fine. Sex acts and porn are never public, and are kept for their owner. Anything sexual involving a minor is blocked, quarantined, and left as a report for the operator.</p></header>
++${saved ? `<p class="vi-note" role="status">Saved. The review is now in the category you chose.</p>` : ''}
++<nav class="vi-review-tabs" aria-label="Review categories">
++${tab(null, 'All reviews', counts.total)}
++${tab('fine', 'Fine', counts.fine)}
++${tab('sex_acts', 'Sex acts or porn', counts.sex_acts)}
++${tab('minor_sexual', 'Sexual content involving a minor', counts.minor_sexual)}
++</nav>
++<p class="vi-review-rules"><b>${Number(counts.reports_open) || 0} open operator report${Number(counts.reports_open) === 1 ? '' : 's'}.</b> Category 2 and 3 pictures are hidden or blurred here. A category-3 picture is evidence: do not save or share it, and open it only through Show on the review page, which is logged. This service does not send anything outside OpenVibe.</p>
++${reviews.length ? `<ul class="vi-review-list">${reviews.map(row).join('')}</ul>` : '<div class="vi-empty"><h2>Nothing in this category</h2><p>New reviews appear here as services send images. Choose another tab or wait for the next review.</p></div>'}
++${reports.length ? `<section class="vi-review-reports" aria-labelledby="vi-reports-h"><h2 id="vi-reports-h">Open operator reports</h2><ul class="vi-report-list">${reports.map(reportRow).join('')}</ul></section>` : ''}
++</div>`;
++}
++
++function item({ review: r, saved = null, problem = null, revealed = null } = {}) {
++ if (!r) return '<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty"><h1>No such review</h1><p>It may have been deleted, or the address was never one of ours.</p><a class="vi-btn" href="/review">Back to the queue</a></div></div>';
++ const choice = (key) => `<label class="vi-review-choice${r.category === key ? ' is-on' : ''}">
++<input type="radio" name="category" value="${key}"${r.category === key ? ' checked' : ''}>
++<span><b>${esc(SHORT[key])}</b><small>${esc(PLAIN[key])}. ${esc(ACTION[key])}</small></span>
++</label>`;
++ const hidden = r.category !== 'fine';
++ return `<div class="vi-wrap vi-review vi-review-one" id="vi-review-one">
++<header class="vi-page-h"><p class="vi-kicker"><a href="/review">Review queue</a> / one image</p><h1>${esc(SHORT[r.category] || r.category)}</h1>
++<p class="vi-lede">${esc(r.plain_words || PLAIN[r.category] || '')}</p></header>
++${saved ? '<p class="vi-note" role="status">Saved. The category is now what you chose.</p>' : ''}
++${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++<article class="vi-review-detail">
++${evidenceFigure(r, revealed)}
++<div class="vi-review-detail-main">
++<div class="vi-review-head">${badge(r.category)}<span class="vi-review-plain">${esc(r.plain_words || PLAIN[r.category] || '')}</span></div>
++<p class="vi-review-reason">${esc(r.reason || 'No reason was given.')}</p>
++${facts(r)}
++${hidden ? `<section class="vi-review-evidence-note" aria-labelledby="vi-evidence-h"><h2 id="vi-evidence-h">Evidence for the report</h2><p>${esc(evidenceWarn)}${r.category === 'minor_sexual' ? ' The object is quarantined; Media opens it only for this 60-second operator view.' : ' The object is hidden from every public surface.'}</p><form method="post" action="/review/${encodeURIComponent(r.id)}/reveal"><button class="vi-btn" type="submit">Show the picture</button><span class="vi-fine">One picture, for 60 seconds; this is logged.</span></form></section>` : ''}
++${r.report ? `<section class="vi-review-report" id="vi-review-report"><h2>Operator report</h2><p><b>${esc(r.report.id)}</b> · ${esc(r.report.status)} · ${time(r.report.created_at)}</p><p>This is the record for the operator. Nothing is sent externally by this service; an external report needs the NCMEC registration the owner holds.</p></section>` : ''}
++${revealLog(r)}
++<form class="vi-review-form" method="post" action="/review/${encodeURIComponent(r.id)}/decide">
++<fieldset class="vi-review-choices"><legend>Sort this image into one category</legend>
++${choice('fine')}
++${choice('sex_acts')}
++${choice('minor_sexual')}
++</fieldset>
++<div class="vi-actions"><button class="vi-btn" type="submit">Save the category</button><a class="vi-btn vi-btn-quiet" href="/review">Back to the queue</a></div>
++<p class="vi-fine">Saving a category records who decided it and when. Choosing a category other than “Sexual content involving a minor” releases Media's evidence hold and dismisses this review's open report, because the operator has decided the image is not that.</p>
++</form>
++</div>
++</article>
++</div>`;
++}
++
++function reports({ reports = [] } = {}) {
++ const rows = reports.map((r) => `<tr><td><code>${esc(r.id)}</code></td><td>${esc(r.object_type)}${r.object_id ? ` <code>${esc(r.object_id)}</code>` : ''}</td><td>${esc(r.status)}</td><td>${time(r.created_at)}</td><td><a href="/review/${encodeURIComponent(r.review_id)}">Open review</a></td></tr>`).join('');
++ return `<div class="vi-wrap vi-review"><header class="vi-page-h"><p class="vi-kicker">Operator review</p><h1>Operator reports</h1><p class="vi-lede">Category-3 reviews make one report row each. They stay here until an operator decides the review.</p></header><div class="table-wrap"><table class="vi-table"><thead><tr><th scope="col">Report</th><th scope="col">Object</th><th scope="col">Status</th><th scope="col">Created</th><th scope="col"></th></tr></thead><tbody>${rows || '<tr><td colspan="5">No reports.</td></tr>'}</tbody></table></div></div>`;
++}
++
++module.exports = { queue, item, reports, PLAIN, SHORT, ACTION, evidenceFigure };
ddiff --git a/server/vision/media-view.js b/server/vision/media-view.js
nnew file mode 100644
iindex 0000000..3d55198
---- /dev/null
++++ b/server/vision/media-view.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++/**
++ * Vision → Media for the operator's evidence view (VIS-6 review follow-up).
++ *
++ * A quarantined object refuses even a signed read. That is correct for everyone except the operator who
++ * presses Show on the review page. This client calls Media's operator routes with Vision's own service
++ * principal: `media.object.review.view` for a 60-second view and `media.object.review.decide` for an
++ * operator's overruling category (which releases Media's evidence hold). Both calls are logged by Media.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++function createMediaView({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(process.env.MEDIA_URL || process.env.OV_MEDIA_URL || 'http://127.0.0.1:4100').replace(/\/+$/, '');
++ const secret = config.oauth && config.oauth.clientSecret;
++ const token = (scope) => (secret ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId || 'vision',
++ clientSecret: secret,
++ audience: 'openvibe.media',
++ scope,
++ fetchImpl,
++ }) : null);
++ const viewTokens = token('media.object.review.view');
++ const decideTokens = token('media.object.review.decide');
++
++ async function call(path, body, tokens, retried = false) {
++ if (!tokens) throw new Error('Media is not configured for Vision (no service principal)');
++ const res = await fetchImpl(`${base}${path}`, {
++ method: 'POST',
++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(10000),
++ });
++ if (res.status === 401 && !retried) { try { tokens.invalidate(); } catch { /* older client */ } return await call(path, body, tokens, true); }
++ const out = await res.json().catch(() => null);
++ if (!res.ok) throw new Error(`Media ${res.status}: ${(out && (out.detail || out.code)) || 'failed'}`);
++ return out;
++ }
++
++ /** POST /api/v2/:app/objects/:id/operator-view → { url, expires_at }. */
++ async function operatorView({ app, objectId, reason = 'operator review' }) {
++ if (!app || !objectId) throw new Error('the review has no Media object to show');
++ return await call(`/api/v2/${encodeURIComponent(app)}/objects/${encodeURIComponent(objectId)}/operator-view`, { reason }, viewTokens);
++ }
++
++ /** Apply an operator's category in Media (releases or places the evidence hold). */
++ async function decide({ app, objectId, category, reportId = null, by = null }) {
++ if (!app || !objectId) return null;
++ return await call(`/api/v2/${encodeURIComponent(app)}/objects/${encodeURIComponent(objectId)}/review-decision`, {
++ category, report_id: reportId, decided_by: by,
++ }, decideTokens);
++ }
++
++ function available() { return Boolean(secret); }
++ return { available, operatorView, decide };
++}
++
++module.exports = { createMediaView };
ddiff --git a/server/vision/review.js b/server/vision/review.js
nnew file mode 100644
iindex 0000000..fc1371e
---- /dev/null
++++ b/server/vision/review.js
@@@ -0,0 +1,311 @@
++'use strict';
++
++/**
++ * The unsafe-image review (VIS-6's last item, decided 2026-11-06).
++ *
++ * An image review sorts into exactly three categories, in the owner's words:
++ *
++ * fine Fine — OK to show, including nudity.
++ * sex_acts Sex acts or porn — never public. The object is hidden from every public surface and kept for
++ * its owner; an adults-only section can show it later.
++ * minor_sexual Sexual content involving a minor — always blocked. The object is quarantined and a report
++ * record is made for the operator. Nothing is sent to an external authority automatically:
++ * that needs the NCMEC registration the owner holds.
++ *
++ * The text classifier is the same rule Live's auto-clip filter uses (nudity is allowed; sex acts are not; a
++ * child plus nudity or sex acts is the hard line). The image classifier asks OpenVibe.AI one strict question
++ * and reads a JSON answer; an AI answer is never the sole judge of a child-safety report, so the operator
++ * sees every category-3 row and can decide it in the review UI. Child-safety hash matching is a separate
++ * service (Cloudflare's CSAM Scanning Tool, NCMEC lists), not this language model.
++ *
++ * No image bytes are stored. A review stores the caller's object reference, a sha256, the category and the
++ * model provenance; a preview URL is kept only so the operator's page can show the picture.
++ */
++const { VisionError } = require('./service');
++const { forModel } = require('./images');
++
++const CATEGORIES = Object.freeze(['fine', 'sex_acts', 'minor_sexual']);
++
++const PLAIN = Object.freeze({
++ fine: 'Fine — OK to show, including nudity',
++ sex_acts: 'Sex acts or porn — never public; kept for its owner',
++ minor_sexual: 'Sexual content involving a minor — always blocked and reported to the operator',
++});
++
++const ACTION = Object.freeze({
++ fine: 'allow',
++ sex_acts: 'hide',
++ minor_sexual: 'quarantine_and_report',
++});
++
++// A preview URL is rendered in Vision's HTML: https only, on a first-party host Vision already trusts
++// (the same origins its CSP allows: its own site, Network and Media). Anything else is refused, never stored.
++const PREVIEW_HOSTS = Object.freeze(['openvibe.vision', 'openvibe.network', 'openvibe.media']);
++function validPreviewUrl(raw) {
++ if (typeof raw !== 'string' || raw.length > 2000) return null;
++ let u;
++ try { u = new URL(raw); } catch { return null; }
++ if (u.protocol !== 'https:') return null;
++ if (u.username || u.password) return null;
++ if (u.port && u.port !== '443') return null;
++ if (!PREVIEW_HOSTS.includes(u.hostname.toLowerCase())) return null;
++ return u.toString();
++}
++
++const MINOR = /\b(?:child(?:ren)?|kids?|minors?|underage|preteens?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|teen(?:age(?:rs?)?|s)?|schoolboys?|schoolgirls?)\b/i;
++const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|nipples?)\b/i;
++// What a scene shows, not what someone talks about in passing: sex acts and pornography. Nudity and body
++// words are deliberately absent (a nude body is not a sex act).
++const SEX = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse|content|explicit|material|scenes?)|sex\s+(?:acts?|scenes?|shows?|clubs?|work|toys?)|having\s+sex|(?:pornographic|explicit\s+adult|adult)\s+(?:content|material|footage|scenes?|imagery|videos?|films?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|anal\s+sex|penetrat\w*|erotic\w*|orgasm\w*|blow\s?job|hand\s?job|nsfw)\b/i;
++
++/** The most severe of categories, so a text hint can never make an image look safer than the image pass says. */
++function worst(...categories) {
++ const rank = { fine: 0, sex_acts: 1, minor_sexual: 2 };
++ return categories.filter((c) => CATEGORIES.includes(c)).sort((a, b) => rank[b] - rank[a])[0] || 'fine';
++}
++
++/** Sort a piece of text (an AI overview, a caption, a report) into the three categories. */
++function classifyText(...texts) {
++ const all = texts.filter((t) => t != null).map(String).join('\n').trim();
++ if (!all) return { category: 'fine', cue: 'empty', reason: 'there is no text to judge' };
++ const minor = MINOR.test(all);
++ const nude = NUDE.test(all);
++ const sex = SEX.test(all);
++ if (minor && (sex || nude)) {
++ return { category: 'minor_sexual', cue: 'minor+nudity_or_sex', reason: 'the text puts a child together with nudity or sex acts' };
++ }
++ if (sex) return { category: 'sex_acts', cue: 'sex_acts', reason: 'the text describes sex acts or pornography' };
++ if (nude) return { category: 'fine', cue: 'nudity', reason: 'the text describes nudity, which is allowed; it does not describe a sex act' };
++ return { category: 'fine', cue: 'none', reason: 'the text describes neither sex acts nor a child with nudity' };
++}
++
++const CLASSIFY_PROMPT = [
++ 'Classify this image for a content reviewer. Return ONLY a JSON object, with no prose and no code fence:',
++ '{"sexual": true or false, "nudity": true or false, "minor": true or false, "sex_acts": true or false, "reason": "one short sentence"}',
++ 'sexual is true when the image shows a sex act or pornography. Nudity alone is allowed and is not sexual.',
++ 'minor is true when anyone in the image appears to be a child or teenager. Do not guess age from an adult',
++ 'who merely looks young; say true only when the picture shows a minor. sex_acts is true only when the image',
++ 'shows a sex act (not kissing, not nudity). If a minor is present and the image shows nudity or a sex act, set',
++ 'sexual true as well: a minor in a sexual image is always the hard line. Never identify or name anyone.',
++].join(' ');
++
++function parseClassification(text) {
++ const raw = String(text || '').trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
++ const at = raw.indexOf('{');
++ const end = raw.lastIndexOf('}');
++ if (at < 0 || end <= at) return null;
++ let body;
++ try { body = JSON.parse(raw.slice(at, end + 1)); } catch { return null; }
++ if (!body || typeof body !== 'object') return null;
++ const flag = (v) => v === true || v === 1 || /^(1|true|yes)$/i.test(String(v));
++ const sexual = flag(body.sexual) || flag(body.sex_acts) || flag(body.sexActs);
++ const nudity = flag(body.nudity) || flag(body.nude);
++ const minor = flag(body.minor);
++ const category = minor && (sexual || nudity) ? 'minor_sexual' : sexual ? 'sex_acts' : 'fine';
++ return {
++ category,
++ sexual,
++ minor,
++ reason: String(body.reason || '').replace(/\s+/g, ' ').slice(0, 300) || null,
++ };
++}
++
++function wireReview(row) {
++ if (!row) return null;
++ const report = row.report && typeof row.report === 'object' ? row.report : null;
++ return {
++ id: row.id,
++ category: row.category,
++ plain_words: row.plain_words || PLAIN[row.category],
++ action: row.action,
++ public: Boolean(row.is_public),
++ status: row.status,
++ object_type: row.object_type,
++ object_id: row.object_id == null ? null : String(row.object_id),
++ object_app: row.object_app == null ? null : row.object_app,
++ media_object_id: row.media_object_id == null ? null : String(row.media_object_id),
++ media_app: row.media_app == null ? null : row.media_app,
++ owner_subject: row.owner_subject == null ? null : row.owner_subject,
++ subject: row.subject == null ? null : row.subject,
++ reason: row.reason == null ? null : row.reason,
++ cue: row.cue == null ? null : row.cue,
++ engine: row.engine,
++ model: row.model == null ? null : row.model,
++ provider: row.provider == null ? null : row.provider,
++ route: row.route == null ? null : row.route,
++ confidence: row.confidence == null ? null : Number(row.confidence),
++ cost_usd: Number(row.cost_usd) || 0,
++ latency_ms: Number(row.latency_ms) || 0,
++ preview_url: row.preview_url == null ? null : row.preview_url,
++ image_sha256: row.image_sha256 == null ? null : row.image_sha256,
++ reviewed_by: row.reviewed_by == null ? null : row.reviewed_by,
++ reviewed_at: row.reviewed_at == null ? null : (row.reviewed_at instanceof Date ? row.reviewed_at.toISOString() : String(row.reviewed_at)),
++ created_at: row.created_at == null ? null : (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
++ report: report ? wireReport(report) : null,
++ reveals: Array.isArray(row.reveals) ? row.reveals.map(wireReveal) : [],
++ };
++}
++
++function wireReveal(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ review_id: row.review_id,
++ operator_subject: row.operator_subject,
++ kind: row.kind,
++ expires_at: row.expires_at == null ? null : (row.expires_at instanceof Date ? row.expires_at.toISOString() : String(row.expires_at)),
++ revealed_at: row.revealed_at == null ? null : (row.revealed_at instanceof Date ? row.revealed_at.toISOString() : String(row.revealed_at)),
++ };
++}
++
++function wireReport(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ review_id: row.review_id,
++ category: row.category,
++ object_type: row.object_type,
++ object_id: row.object_id == null ? null : String(row.object_id),
++ object_app: row.object_app == null ? null : row.object_app,
++ media_object_id: row.media_object_id == null ? null : String(row.media_object_id),
++ media_app: row.media_app == null ? null : row.media_app,
++ owner_subject: row.owner_subject == null ? null : row.owner_subject,
++ reason: row.reason == null ? null : row.reason,
++ status: row.status,
++ created_at: row.created_at == null ? null : (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
++ resolved_at: row.resolved_at == null ? null : (row.resolved_at instanceof Date ? row.resolved_at.toISOString() : String(row.resolved_at)),
++ };
++}
++
++function createReview({ config, s, ai, store, mediaView = null, log = console }) {
++ /** When the image pass fails, only already-severe text may answer; harmless text can never clear it. */
++ function textOrUnavailable(byText, why) {
++ if (byText && (byText.category === 'sex_acts' || byText.category === 'minor_sexual')) {
++ return { ...byText, plain: PLAIN[byText.category], action: ACTION[byText.category], public: false, confidence: null, reason: `${byText.reason} (${why})` };
++ }
++ throw new VisionError(503, 'vision.review_unavailable', `${why}; harmless text cannot clear an image the reviewer could not judge`);
++ }
++
++ /**
++ * classify({ image, text }) → { category, plain_words, action, reason, cue, engine, model, ... }.
++ * Text alone is enough (Live's AI overviews); an image asks OpenVibe.AI for the strict JSON above.
++ */
++ async function classify({ image = null, text = null } = {}) {
++ const byText = text && String(text).trim() ? { ...classifyText(text), engine: 'text', model: null, provider: null, route: 'text.rule', costUsd: 0, ms: 0 } : null;
++ if (!image) {
++ if (!byText) throw new VisionError(400, 'vision.review_invalid', 'send an image or text to review');
++ return { ...byText, plain: PLAIN[byText.category], action: ACTION[byText.category], public: byText.category === 'fine', confidence: null };
++ }
++ if (!ai.enabled) return textOrUnavailable(byText, 'OpenVibe.AI is not configured on this host; the image cannot be judged');
++ const normal = await forModel(image.buffer);
++ const started = Date.now();
++ let a;
++ try {
++ a = await ai.answer({ dataUrl: normal.dataUrl, task: 'ask', question: CLASSIFY_PROMPT, maxTokens: 300, temperature: 0 });
++ } catch (err) {
++ return textOrUnavailable(byText, `the image pass was unavailable (${(err && (err.detail || err.message)) || 'no answer'})`);
++ }
++ const parsed = parseClassification(a.text);
++ if (!parsed) return textOrUnavailable(byText, 'the image pass returned no usable classification');
++ const category = worst(parsed.category, byText ? byText.category : 'fine');
++ return {
++ category,
++ plain: PLAIN[category],
++ action: ACTION[category],
++ public: category === 'fine',
++ reason: parsed.reason || (byText && byText.reason) || null,
++ cue: parsed.category === category ? (parsed.minor ? 'image:minor' : parsed.sexual ? 'image:sex_acts' : parsed.nudity ? 'image:nudity' : 'image:fine') : (byText && byText.cue) || 'image',
++ engine: byText ? 'text+ai' : 'ai',
++ model: a.model,
++ provider: a.provider,
++ route: a.route,
++ confidence: null,
++ costUsd: a.costUsd,
++ ms: Date.now() - started,
++ image,
++ };
++ }
++
++ /** Classify and store one review; a category-3 row always creates the operator's report record. */
++ async function review({ requester, subject = null, image = null, text = null, objectType = 'unknown', objectId = null, objectApp = null, ownerSubject = null, previewUrl = null, mediaObjectId = null, mediaApp = null, note = null }) {
++ const got = await classify({ image, text });
++ // A person's own review without an object owner belongs to them; a service names the owner explicitly.
++ const owner = ownerSubject || subject || null;
++ const saved = await store.saveReview({
++ subject, requester, objectType, objectId, objectApp, ownerSubject: owner, mediaObjectId, mediaApp,
++ imageSha256: image ? image.sha256 : null, imageMime: image ? image.mime : null, imageBytes: image ? image.bytes : null,
++ previewUrl, category: got.category, plainWords: PLAIN[got.category], action: ACTION[got.category],
++ public: got.category === 'fine', reason: note ? `${got.reason || ''}${got.reason ? ' — ' : ''}${String(note).slice(0, 300)}` : got.reason,
++ cue: got.cue, engine: got.engine, model: got.model, provider: got.provider, route: got.route,
++ confidence: got.confidence, costUsd: got.costUsd || 0, latencyMs: got.ms || 0,
++ });
++ if (got.category === 'minor_sexual') {
++ const report = await store.createOperatorReport({
++ reviewId: saved.id, category: got.category, objectType, objectId, ownerSubject: owner,
++ reason: got.reason || 'sexual content involving a minor',
++ });
++ if (!report) log.error(`[Vision] review ${saved.id}: category minor_sexual but no operator report was recorded`);
++ return store.reviewWithReport(saved.id);
++ }
++ return saved;
++ }
++
++ /** An operator's explicit decision: exactly one of the three categories, never a new one. */
++ async function decide(id, category, by) {
++ if (!CATEGORIES.includes(category)) throw new VisionError(400, 'vision.review_invalid_category', `category must be one of ${CATEGORIES.join(', ')}`);
++ const got = await store.reviewWithReport(id);
++ if (!got) throw new VisionError(404, 'vision.review_not_found', 'No such review.');
++ await store.decideReview(id, {
++ category, plainWords: PLAIN[category], action: ACTION[category], public: category === 'fine', reviewedBy: by,
++ });
++ let report = got.report;
++ if (category === 'minor_sexual') {
++ const existing = await store.operatorReportFor(id);
++ report = existing || await store.createOperatorReport({
++ reviewId: id, category, objectType: got.object_type, objectId: got.object_id,
++ ownerSubject: got.owner_subject, reason: got.reason || 'operator marked sexual content involving a minor',
++ });
++ } else {
++ await store.dismissOperatorReport(id, by);
++ report = null;
++ }
++ // Media releases (or places) its evidence hold only through this operator decision. A failure is
++ // surfaced so the operator can repeat the decision; the hold is not silently left open.
++ if (mediaView && (got.media_object_id || (got.object_type === 'media.object' && got.object_id))) {
++ const app = got.media_app || got.object_app;
++ try {
++ await mediaView.decide({ app, objectId: got.media_object_id || got.object_id, category, reportId: report ? report.id : null, by });
++ } catch (err) {
++ throw new VisionError(502, 'vision.media_hold_unreleased', `The review was saved, but Media did not release its evidence hold: ${(err && err.message) || err}`);
++ }
++ }
++ return store.reviewWithReport(id);
++ }
++
++ /** The operator's one-picture reveal: Media's 60-second view for a quarantined/hidden object, logged. */
++ async function reveal(id, operator) {
++ const got = await store.reviewById(id);
++ if (!got) throw new VisionError(404, 'vision.review_not_found', 'No such review.');
++ if (!['sex_acts', 'minor_sexual'].includes(got.category)) throw new VisionError(409, 'vision.review_not_hidden', 'Only a hidden or quarantined review needs an operator view.');
++ let out = null;
++ if (mediaView && (got.media_object_id || (got.object_type === 'media.object' && got.object_id))) {
++ out = await mediaView.operatorView({ app: got.media_app || got.object_app, objectId: got.media_object_id || got.object_id });
++ if (!out || !out.url) throw new VisionError(502, 'vision.media_view_unavailable', 'Media did not return an operator view.');
++ const row = await store.saveReviewReveal({ reviewId: id, operatorSubject: operator, kind: 'media_operator_view', expiresAt: out.expires_at || null });
++ return { url: out.url, expires_at: out.expires_at || null, reveal: row };
++ }
++ // A service that sent no Media object can still supply a first-party preview URL; it is logged too.
++ if (got.preview_url && validPreviewUrl(got.preview_url)) {
++ const row = await store.saveReviewReveal({ reviewId: id, operatorSubject: operator, kind: 'preview', expiresAt: null });
++ return { url: got.preview_url, expires_at: null, reveal: row };
++ }
++ throw new VisionError(409, 'vision.review_no_evidence_view', 'This review has no Media object to open for the operator.');
++ }
++
++ return { classify, review, decide, reveal };
++}
++
++module.exports = {
++ CATEGORIES, PLAIN, ACTION, MINOR, NUDE, SEX,
++ classifyText, parseClassification, worst, wireReview, wireReport, wireReveal, validPreviewUrl, PREVIEW_HOSTS, createReview,
++};
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex eff4d7e..ba19ad1 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -47,7 +47,10 @@ async function startNetworkStub() {
try {
const missing = await uncovered(d.db, {
tables: TABLES,
-- exempt: {account_data_events: 'the ledger of requests itself: which export and deletion vision applied'},
++ exempt: {
++ account_data_events: 'the ledger of requests itself: which export and deletion vision applied',
++ vision_review_reveals: 'operator audit: who opened evidence and when, kept with the report for accountability',
++ },
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
@@@ -62,6 +65,8 @@ async function startNetworkStub() {
const aCheck = await t.ctx.store.saveUiCheck({ subject: A, requester: `user:${A}`, source: 'html', viewport: { w: 390, h: 844 }, engine: 'static-html', findings: [{ kind: 'overflow' }], summary: { summary: 'one fault' } });
const bCheck = await t.ctx.store.saveUiCheck({ subject: B, requester: `user:${B}`, source: 'url', url: 'https://openvibe.vision/', viewport: { w: 1280, h: 800 }, engine: 'static-html', findings: [], summary: {} });
const aCompare = await t.ctx.store.saveCompare({ subject: A, requester: `user:${A}`, task: 'ask', image: IMAGE, question: 'compare', samples: [{ engine: 'local', model: 'tesseract', provider: 'local', route: 'local.ocr', ok: true, text: 'A text', costUsd: 0, ms: 4 }] });
++ const aReview = await t.ctx.store.saveReview({ subject: A, requester: `user:${A}`, objectType: 'media.object', objectId: 'med_a', ownerSubject: A, category: 'fine', plainWords: 'Fine', action: 'allow', public: true, engine: 'text', reason: 'A review' });
++ const bReview = await t.ctx.store.saveReview({ subject: B, requester: `user:${B}`, objectType: 'media.object', objectId: 'med_b', ownerSubject: B, category: 'sex_acts', plainWords: 'Sex acts or porn', action: 'hide', public: false, engine: 'text', reason: 'B review' });
await check('an export carries only A\'s answers, compare samples and UI checks', async () => {
const body = bodyOf(exportEvent);
@@@ -72,12 +77,15 @@ async function startNetworkStub() {
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_vision');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['answers.json', 'compare.json', 'ui-checks.json']);
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['answers.json', 'compare.json', 'image-reviews.json', 'ui-checks.json']);
const all = JSON.stringify(part.body);
assert.ok(all.includes(aAnswer.id));
assert.ok(all.includes(aCheck.id));
assert.ok(all.includes(aCompare.answer.id));
++ assert.ok(all.includes(aReview.id));
assert.ok(!all.includes(bAnswer.id));
++ assert.ok(!all.includes(bReview.id));
++ assert.ok(!all.includes('B review'));
assert.ok(!all.includes(bCheck.id));
assert.ok(!all.includes('B answer'));
const answers = part.body.files.find((f) => f.name === 'answers.json').content;
@@@ -94,6 +102,8 @@ async function startNetworkStub() {
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_answers WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_ui_checks WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_compare_samples WHERE subject = $1', [A])), 0);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_image_reviews WHERE owner_subject = $1', [A])), 0);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_image_reviews WHERE owner_subject = $1', [B])), 1);
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_answers WHERE subject = $1', [B])), 1);
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_ui_checks WHERE subject = $1', [B])), 1);
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
@@@ -101,6 +111,7 @@ async function startNetworkStub() {
assert.ok(confirmation.body.erased.vision_answers >= 1);
assert.ok(confirmation.body.erased.vision_ui_checks >= 1);
assert.ok(confirmation.body.erased.vision_compare_samples >= 1);
++ assert.ok(confirmation.body.erased.vision_image_reviews >= 1);
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 37e2125..08c5d92 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -46,7 +46,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, eventPublisher: opts.eventPublisher, ocr: opts.ocr, ai: opts.aiClient, facesModel: opts.facesModel, clip: opts.clip, detector: opts.detector });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, eventPublisher: opts.eventPublisher, ocr: opts.ocr, ai: opts.aiClient, facesModel: opts.facesModel, clip: opts.clip, detector: opts.detector, mediaView: opts.mediaView, review: opts.review });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/review.test.js b/test/review.test.js
nnew file mode 100644
iindex 0000000..76aa502
---- /dev/null
++++ b/test/review.test.js
@@@ -0,0 +1,215 @@
++'use strict';
++/**
++ * VIS-6's last item: the unsafe-image review sorts into exactly three categories, and each one acts:
++ *
++ * fine allowed, including nudity — no report, public stays true
++ * sex_acts hidden from public surfaces, kept for its owner — action hide, public false
++ * minor_sexual blocked everywhere and quarantined, with an operator report row — action
++ * quarantine_and_report, public false, report open
++ *
++ * Also: text alone is enough (Live's AI overviews), only staff may use the queue, an operator's decision
++ * takes exactly one of the same three (never a fourth label), and the staff page shows the plain words.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const mediaView = {
++ available: () => true,
++ decide: async () => ({ ok: true }),
++ operatorView: async ({ objectId }) => ({ url: `https://openvibe.media/o/${objectId}?exp=1&sig=x`, expires_at: new Date(Date.now() + 60_000).toISOString() }),
++ };
++ const t = await boot({ mediaView });
++ const staff = t.network.addUser('ops', { role: 'admin' });
++ const person = t.network.addUser('kim');
++ const other = t.network.addUser('sam');
++ const png = await t.textImage('A REVIEW FIXTURE');
++ const reviewToken = () => t.network.serviceToken({ sub: 'svc:media', cap: ['vision.image.review'] });
++ const readToken = () => t.network.serviceToken({ sub: 'svc:ops', cap: ['vision.review.read', 'vision.review.decide'] });
++
++ try {
++ await check('text alone: nudity is fine, sex acts are not, a child with nudity is the hard line', async () => {
++ const fine = await t.get('/api/v1/review', { bearer: reviewToken(), json: { text: 'A nude figure drawing session; the model is naked on a bed.', object_type: 'live.clip', object_id: '9' } });
++ assert.strictEqual(fine.status, 201, fine.text);
++ assert.deepStrictEqual([fine.json().category, fine.json().action, fine.json().public], ['fine', 'allow', true]);
++ assert.strictEqual(fine.json().review.engine, 'text');
++
++ const sex = await t.get('/api/v1/review', { bearer: reviewToken(), json: { text: 'Two people having sex on a bed; pornographic footage.', object_type: 'live.clip', object_id: '10' } });
++ assert.deepStrictEqual([sex.json().category, sex.json().action, sex.json().public], ['sex_acts', 'hide', false]);
++
++ const minor = await t.get('/api/v1/review', { bearer: reviewToken(), json: { text: 'Two kids appear nude in the background.', object_type: 'community.paste', object_id: 'abc' } });
++ assert.deepStrictEqual([minor.json().category, minor.json().action, minor.json().public], ['minor_sexual', 'quarantine_and_report', false]);
++ assert.strictEqual(minor.json().report.status, 'open');
++ assert.match(minor.json().plain_words, /always blocked/);
++ });
++
++ await check('an image is classified by OpenVibe.AI into the three categories, never a fourth', async () => {
++ t.aiServer.setText('{"sexual": false, "minor": false, "sex_acts": false, "reason": "a figure drawing; nudity is allowed"}');
++ let r = await t.get('/api/v1/review', {
++ bearer: reviewToken(),
++ multipart: { fields: { object_type: 'media.object', object_id: 'med_fine' }, image: png },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ const fine = r.json();
++ assert.deepStrictEqual([fine.category, fine.action, fine.public, fine.review.engine], ['fine', 'allow', true, 'ai']);
++ assert.strictEqual(fine.report, null);
++ assert.ok(t.aiServer.requests.some((x) => x.path === '/v1/chat/completions' && /sexual|sex acts/.test(JSON.stringify(x.body))));
++
++ t.aiServer.setText('{"sexual": true, "minor": false, "sex_acts": true, "reason": "a sex act is visible"}');
++ r = await t.get('/api/v1/review', {
++ bearer: reviewToken(),
++ multipart: { fields: { object_type: 'media.object', object_id: 'med_sex' }, image: png },
++ });
++ const sex = r.json();
++ assert.deepStrictEqual([sex.category, sex.action, sex.public, sex.report], ['sex_acts', 'hide', false, null]);
++
++ t.aiServer.setText('{"sexual": true, "minor": true, "sex_acts": true, "reason": "a child is shown in a sex act"}');
++ r = await t.get('/api/v1/review', {
++ bearer: reviewToken(),
++ multipart: { fields: { object_type: 'media.object', object_id: 'med_hard' }, image: png },
++ });
++ const hard = r.json();
++ assert.deepStrictEqual([hard.category, hard.action, hard.public], ['minor_sexual', 'quarantine_and_report', false]);
++ assert.strictEqual(hard.report.status, 'open');
++ assert.match(hard.report.id, /^vor_/);
++
++ // A minor shown nude is the hard line even when the model does not call the picture a sex act.
++ t.aiServer.setText('{"sexual": false, "nudity": true, "minor": true, "sex_acts": false, "reason": "a naked child"}');
++ r = await t.get('/api/v1/review', {
++ bearer: reviewToken(),
++ multipart: { fields: { object_type: 'media.object', object_id: 'med_hard_nude' }, image: png },
++ });
++ assert.strictEqual(r.json().category, 'minor_sexual');
++ assert.strictEqual(r.json().report.status, 'open');
++ });
++
++ await check('the image pass failing never clears an image: only severe text may answer, else 503', async () => {
++ t.aiServer.setFail(true);
++ let r = await t.get('/api/v1/review', { bearer: reviewToken(), multipart: { fields: { text: 'a harmless caption' }, image: png } });
++ assert.deepStrictEqual([r.status, r.json().code], [503, 'vision.review_unavailable']);
++ r = await t.get('/api/v1/review', { bearer: reviewToken(), multipart: { fields: { text: 'a naked child in the frame' }, image: png } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().category, 'minor_sexual');
++ r = await t.get('/api/v1/review', { bearer: reviewToken(), multipart: { fields: { text: 'pornographic footage' }, image: png } });
++ assert.strictEqual(r.json().category, 'sex_acts');
++ r = await t.get('/api/v1/review', { bearer: reviewToken(), multipart: { image: png } });
++ assert.strictEqual(r.status, 503);
++ t.aiServer.setFail(false);
++ t.aiServer.setText('I cannot help with that');
++ r = await t.get('/api/v1/review', { bearer: reviewToken(), multipart: { fields: { text: 'a harmless caption' }, image: png } });
++ assert.deepStrictEqual([r.status, r.json().code], [503, 'vision.review_unavailable']);
++ t.aiServer.setText(null);
++ });
++
++ await check('preview_url is https on a first-party host only; javascript: and http: are 400', async () => {
++ for (const bad of ['javascript:alert(1)', 'http://openvibe.media/o/med_x', 'https://evil.example/x']) {
++ const r = await t.get('/api/v1/review', { bearer: reviewToken(), json: { text: 'a harmless caption', preview_url: bad } });
++ assert.deepStrictEqual([r.status, r.json().code], [400, 'vision.invalid_preview_url'], bad);
++ }
++ const ok = await t.get('/api/v1/review', { bearer: reviewToken(), json: { text: 'a harmless caption', preview_url: 'https://openvibe.media/o/med_x' } });
++ assert.strictEqual(ok.status, 201, ok.text);
++ assert.strictEqual(ok.json().review.preview_url, 'https://openvibe.media/o/med_x');
++ });
++
++ await check('operators reveal one picture: staff only, hidden/quarantined only, logged on the report', async () => {
++ t.aiServer.setText('{"sexual": true, "nudity": true, "minor": true, "sex_acts": true, "reason": "a child in a sexual image"}');
++ const made = await t.get('/api/v1/review', {
++ bearer: reviewToken(),
++ multipart: { fields: { object_type: 'media.object', object_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', object_app: 'live', media_object_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', media_app: 'live' }, image: png },
++ });
++ const id = made.json().review.id;
++ assert.strictEqual((await t.get(`/api/v1/review/${id}/reveal`, { as: person, method: 'POST', json: {} })).status, 403);
++ const r = await t.get(`/api/v1/review/${id}/reveal`, { as: staff, method: 'POST', json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.json().view.url, /^https:\/\/openvibe\.media\/o\/med_/);
++ assert.strictEqual(r.json().view.reveal.operator_subject, staff.subject);
++ const read = await t.get(`/api/v1/review/${id}`, { as: staff });
++ assert.strictEqual(read.json().review.reveals.length, 1);
++ assert.strictEqual(read.json().review.reveals[0].kind, 'media_operator_view');
++
++ const fine = await t.get('/api/v1/review', { bearer: reviewToken(), json: { text: 'a grocery receipt', object_type: 'media.object', object_id: 'med_fine_2' } });
++ const no = await t.get(`/api/v1/review/${fine.json().review.id}/reveal`, { as: staff, method: 'POST', json: {} });
++ assert.deepStrictEqual([no.status, no.json().code], [409, 'vision.review_not_hidden']);
++ });
++
++ await check('the detail page withholds a category-3 picture until Show, and logs the reveal', async () => {
++ t.aiServer.setText('{"sexual": true, "minor": true, "sex_acts": true, "reason": "a child in a sexual image"}');
++ const made = await t.get('/api/v1/review', {
++ bearer: reviewToken(),
++ multipart: { fields: { object_type: 'media.object', object_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', object_app: 'live', media_object_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', media_app: 'live' }, image: png },
++ });
++ const id = made.json().review.id;
++ const page = await t.get(`/review/${id}`, { as: staff });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Show the picture'));
++ assert.ok(page.text.includes('do not save it, copy it or share it'));
++ assert.ok(!page.text.includes('?exp=1&amp;sig=x'), 'no evidence URL before Show');
++ const shown = await t.get(`/review/${id}/reveal`, { as: staff, method: 'POST', form: {} });
++ assert.strictEqual(shown.status, 200, shown.text);
++ assert.ok(shown.text.includes('https://openvibe.media/o/med_'));
++ assert.ok(shown.text.includes('Operator views'));
++ });
++
++ await check('a stored review row keeps the category and one operator report for category 3', async () => {
++ const row = await t.ctx.s.db.maybe("SELECT category, action, is_public FROM vision_image_reviews WHERE object_id = 'med_hard' ORDER BY created_at DESC LIMIT 1");
++ assert.deepStrictEqual([row.category, row.action, row.is_public], ['minor_sexual', 'quarantine_and_report', false]);
++ const reports = await t.ctx.s.db.many('SELECT status FROM vision_operator_reports');
++ assert.ok(reports.length >= 2 && reports.every((x) => x.status === 'open'));
++ const counts = (await t.get('/api/v1/review', { bearer: readToken() })).json().counts;
++ assert.ok(counts.minor_sexual >= 3);
++ assert.ok(counts.sex_acts >= 2);
++ assert.ok(counts.fine >= 1);
++ assert.ok(counts.reports_open >= 3);
++ });
++
++ await check('a person must be staff: the queue and a decision are refused, the owner can read their own review', async () => {
++ const denied = await t.get('/api/v1/review', { as: person });
++ assert.deepStrictEqual([denied.status, denied.json().code], [403, 'vision.staff_only']);
++ const no = await t.get('/api/v1/review', { as: person, json: { text: 'a naked child' } });
++ assert.strictEqual(no.status, 403);
++ const tok = t.network.serviceToken({ sub: 'svc:media', cap: ['vision.image.review'] });
++ const own = await t.get('/api/v1/review', { bearer: tok, json: { text: 'a naked man on a bed', object_type: 'community.paste', object_id: 'owner-read', owner_subject: person.subject } });
++ const id = own.json().review.id;
++ assert.strictEqual((await t.get(`/api/v1/review/${id}`, { as: person })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/review/${id}`, { as: other })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/review/${id}`, { bearer: reviewToken() })).status, 403, 'image.review alone cannot read the queue');
++ assert.strictEqual((await t.get(`/api/v1/review/${id}`, { bearer: readToken() })).status, 200);
++ });
++
++ await check('an operator decides with exactly one of the three; a fourth label is refused', async () => {
++ const listed = (await t.get('/api/v1/review?category=minor_sexual', { bearer: readToken() })).json();
++ const target = listed.reviews[0];
++ const fourth = await t.get(`/api/v1/review/${target.id}/decide`, { bearer: readToken(), json: { category: 'nudity_is_fine' } });
++ assert.deepStrictEqual([fourth.status, fourth.json().code], [400, 'vision.review_invalid_category']);
++ const decided = await t.get(`/api/v1/review/${target.id}/decide`, { bearer: readToken(), json: { category: 'sex_acts' } });
++ assert.strictEqual(decided.status, 200, decided.text);
++ assert.deepStrictEqual([decided.json().category, decided.json().status, decided.json().report.status], ['sex_acts', 'decided', 'dismissed']);
++ });
++
++ await check('staff see the queue page with exactly the three plain categories; non-staff do not', async () => {
++ const page = await t.get('/review', { as: staff });
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('Unsafe-image review'));
++ assert.ok(page.text.includes('Fine — OK to show, including nudity'));
++ assert.ok(page.text.includes('Sex acts or porn — never public; kept for its owner'));
++ assert.ok(page.text.includes('Sexual content involving a minor — always blocked and reported to the operator'));
++ assert.ok(!/category=nudity|category=other|category=maybe/.test(page.text));
++ const denied = await t.get('/review', { as: person });
++ assert.strictEqual(denied.status, 403);
++ const signedOut = await t.get('/review');
++ assert.strictEqual(signedOut.status, 401);
++ });
++
++ await check('the staff form saves one of the same three categories', async () => {
++ const listed = (await t.get('/api/v1/review', { bearer: readToken() })).json();
++ const target = listed.reviews.find((r) => r.category !== 'minor_sexual');
++ const r = await t.get(`/review/${target.id}/decide`, { as: staff, form: { category: 'fine' } });
++ assert.strictEqual(r.status, 303, r.text);
++ const row = await t.ctx.s.db.maybe('SELECT category, status FROM vision_image_reviews WHERE id = $1', [target.id]);
++ assert.deepStrictEqual([row.category, row.status], ['fine', 'decided']);
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });