openvibers/openvibe.vision Commits
Unsafe-image review: three categories, an operator queue and the API consumers call (#88)
Changes
@@@ -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@@@ -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 theclient `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.++## PagesAll 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 aOffer 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 -->@@@ -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"],nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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);@@@ -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",@@@ -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",@@@ -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 flowunder 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; }++}@@@ -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@@@ -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) => {@@@ -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',@@@ -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) {@@@ -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' },@@@ -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">@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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)));});@@@ -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}`;nnew file mode 100644@@@ -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&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); });
@@ -2,6 +2,19 @@
@@ -2,6 +2,19 @@
What changed in OpenVibe.Vision, newest first.
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
## Unreleased — VIS-5
- Added a Streamable HTTP MCP endpoint with eleven tools, protected-resource metadata, token challenges and the
- Added a Streamable HTTP MCP endpoint with eleven tools, protected-resource metadata, token challenges and the
@@ -104,7 +104,7 @@ General answer tables store no image bytes, token or provider key. The opt-in fa
@@ -104,7 +104,7 @@ General answer tables store no image bytes, token or provider key. The opt-in fa
## Capabilities and grants
## 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.
private media and face identity operations also require the verified owner or the person's purpose-scoped consent.
| Capability | Routes | Typical caller |
| Capability | Routes | Typical caller |
@@ -123,6 +123,8 @@ private media and face identity operations also require the verified owner or th
@@ -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.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.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.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
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.
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`).
@@ -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/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/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. |
| `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:
Example — the call Design's Arena makes:
@@ -193,6 +200,41 @@ Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mo
@@ -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.enrolment_required`, `faces.one_face_required`, `faces.model_unavailable`, `faces.key_unavailable`,
`faces.live_grant_required`.
`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
## Pages
All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, semantic HTML
All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, semantic HTML
@@ -215,6 +257,9 @@ person is `private, no-store`.
@@ -215,6 +257,9 @@ person is `private, no-store`.
| `/people/privacy` | the face notice: retention, erasure, liveness and what never happens |
| `/people/privacy` | the face notice: retention, erasure, liveness and what never happens |
| `/privacy` | the shared legal privacy policy |
| `/privacy` | the shared legal privacy policy |
| `/developers` | the API, capabilities and what leaves the host |
| `/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 |
| 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
`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
@@ -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.
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 -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.114.0
- openvibe-shared: v4.114.0
<!-- versions:end -->
<!-- versions:end -->
@@ -4,15 +4,15 @@
@@ -4,15 +4,15 @@
"stage": "product",
"stage": "product",
"code": true,
"code": true,
"wave": "W21",
"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)",
"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",
"port": "5130",
"domain": "openvibe.vision",
"domain": "openvibe.vision",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.114.0",
"shared": "openvibe-shared v4.114.0",
"packages": [
"packages": [
- "openvibe-contracts v0.289.1",
+ "openvibe-contracts v0.294.0",
"openvibe-sdk v0.82.0",
"openvibe-sdk v0.82.0",
"openvibe-shared v4.114.0"
"openvibe-shared v4.114.0"
],
],
@@ -36,7 +36,10 @@
@@ -36,7 +36,10 @@
"vision.face.library",
"vision.face.library",
"vision.face.cast.read",
"vision.face.cast.read",
"vision.models.read",
"vision.models.read",
- "vision.benchmarks.read"
+ "vision.benchmarks.read",
+ "vision.image.review",
+ "vision.review.read",
+ "vision.review.decide"
],
],
"outboundCapabilities": [
"outboundCapabilities": [
"ai.run.create",
"ai.run.create",
@@ -58,6 +61,7 @@
@@ -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",
"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",
"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",
"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",
"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",
"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",
"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 @@
@@ -88,6 +92,7 @@
"account export and deletion",
"account export and deletion",
"Network sign-in",
"Network sign-in",
"server-rendered pages",
"server-rendered pages",
+ "unsafe-image review queue and three-category API",
"discovery",
"discovery",
"deploy files"
"deploy files"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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);
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.0",
"onnxruntime-node": "^1.31.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2625,9 +2625,9 @@
@@ -2625,9 +2625,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.294.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
+ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.0",
"onnxruntime-node": "^1.31.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -369,3 +369,80 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -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
/* 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. */
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; } }
@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; }
+}
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* OpenVibe.Vision — Express app factory; server/index.js listens, tests build their own instance with a temp
* 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.
* 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)
* /api/v1/* the API (http/api.js, http/faces-api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
@@ -40,6 +40,8 @@ const { createStore } = require('./store');
@@ -40,6 +40,8 @@ const { createStore } = require('./store');
const { createOcr } = require('./vision/ocr');
const { createOcr } = require('./vision/ocr');
const { createAi } = require('./vision/ai');
const { createAi } = require('./vision/ai');
const { createVision } = require('./vision/service');
const { createVision } = require('./vision/service');
+const { createReview } = require('./vision/review');
+const { createMediaView } = require('./vision/media-view');
const { createFacesModel } = require('./vision/faces-model');
const { createFacesModel } = require('./vision/faces-model');
const { createFaces } = require('./vision/faces');
const { createFaces } = require('./vision/faces');
const { createFaceLibraries } = require('./vision/face-libraries');
const { createFaceLibraries } = require('./vision/face-libraries');
@@ -71,6 +73,8 @@ async function createApp(opts = {}) {
@@ -71,6 +73,8 @@ async function createApp(opts = {}) {
const ocr = opts.ocr || createOcr({ config, log });
const ocr = opts.ocr || createOcr({ config, log });
const ai = opts.ai || createAi({ config, fetchImpl, log });
const ai = opts.ai || createAi({ config, fetchImpl, log });
const vision = createVision({ config, s, ocr, ai, store, 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 facesModel = opts.facesModel || createFacesModel({ dir: config.faces.modelsDir });
const faces = createFaces({ s, config, model: facesModel });
const faces = createFaces({ s, config, model: facesModel });
const consentRegistry = createConsentRegistry({ config, fetchImpl, log });
const consentRegistry = createConsentRegistry({ config, fetchImpl, log });
@@ -89,7 +93,7 @@ async function createApp(opts = {}) {
@@ -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.
// 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 eventOutbox = opts.eventOutbox || createVisionOutbox({ s, config, log, fetchImpl });
const eventPublisher = opts.eventPublisher || createPublisher({ outbox: eventOutbox, log });
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();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -23,9 +23,11 @@ const { http } = require('openvibe-contracts');
@@ -23,9 +23,11 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { loadImage } = require('../vision/images');
const { loadImage } = require('../vision/images');
const { VisionError } = require('../vision/service');
const { VisionError } = require('../vision/service');
+const { CATEGORIES, validPreviewUrl } = require('../vision/review');
+const { isStaff } = require('../identity/staff');
function createApi(ctx) {
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 r = asyncRouter();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
@@ -253,6 +255,98 @@ function createApi(ctx) {
@@ -253,6 +255,98 @@ function createApi(ctx) {
return res.json(row);
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) ───────
// ── Model hints and benchmarks (no subject data) ───────
let modelCache = null;
let modelCache = null;
r.get('/models', principal.optional('vision.models.read', { allowAnonymous: true }), async (_req, res) => {
r.get('/models', principal.optional('vision.models.read', { allowAnonymous: true }), async (_req, res) => {
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* /find, /read, /watch the network tools (private search, receipts, camera)
* /find, /read, /watch the network tools (private search, receipts, camera)
* /people the four face consents, each with its own signed switch
* /people the four face consents, each with its own signed switch
* /people/privacy face retention, liveness and what never happens
* /people/privacy face retention, liveness and what never happens
+ * /review, /review/:id, /review/reports the unsafe-image review (staff only)
* /developers the API
* /developers the API
* 404
* 404
*/
*/
@@ -29,11 +30,12 @@ const { sameOrigin } = require('./principal');
@@ -29,11 +30,12 @@ const { sameOrigin } = require('./principal');
const { loadImage } = require('../vision/images');
const { loadImage } = require('../vision/images');
const { check: uiCheck } = require('../vision/ui-check');
const { check: uiCheck } = require('../vision/ui-check');
const { VisionError } = require('../vision/service');
const { VisionError } = require('../vision/service');
-const { html, raw } = require('../render/html');
+const { html, raw, esc } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { isStaff } = require('../identity/staff');
const { isStaff } = require('../identity/staff');
const views = require('../views');
const views = require('../views');
const peopleViews = require('../views/people');
const peopleViews = require('../views/people');
+const reviewViews = require('../views/review');
const toolViews = require('../views/tools');
const toolViews = require('../views/tools');
const PUBLIC_CACHE = 'public, max-age=60';
const PUBLIC_CACHE = 'public, max-age=60';
@@ -41,7 +43,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -41,7 +43,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const SAMPLE_IMAGES = { receipt: 'sample-receipt.png', sign: 'sample-sign.png' };
const SAMPLE_IMAGES = { receipt: 'sample-receipt.png', sign: 'sample-sign.png' };
function createPageRoutes(ctx) {
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 r = asyncRouter();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 2 } });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 2 } });
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
@@ -363,6 +365,76 @@ function createPageRoutes(ctx) {
@@ -363,6 +365,76 @@ function createPageRoutes(ctx) {
return page(req, res, { title: 'A UI check', body: raw(views.uiCheckResult({ c: row, mine })), cache: null });
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 ───────────────────────────────────────────
// ── Developers ───────────────────────────────────────────
r.get('/developers', (req, res) => page(req, res, {
r.get('/developers', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Vision API',
index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Vision API',
@@ -29,6 +29,7 @@ const CAPABILITIES = Object.freeze([
@@ -29,6 +29,7 @@ const CAPABILITIES = Object.freeze([
'vision.face.consent.manage', 'vision.face.verify.self', 'vision.face.find.self',
'vision.face.consent.manage', 'vision.face.verify.self', 'vision.face.find.self',
'vision.face.library', 'vision.face.cast.match', 'vision.face.cast.read',
'vision.face.library', 'vision.face.cast.match', 'vision.face.cast.read',
'vision.models.read', 'vision.benchmarks.read',
'vision.models.read', 'vision.benchmarks.read',
+ 'vision.image.review', 'vision.review.read', 'vision.review.decide',
]);
]);
function decodePayload(token) {
function decodePayload(token) {
@@ -22,6 +22,9 @@ const TABLES = [
@@ -22,6 +22,9 @@ const TABLES = [
{ table: 'face_media', subject: 'subject', value: (usr) => usr, file: 'face-media.json', erase: 'delete' },
{ 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: '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' },
{ 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.
// 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_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' },
{ table: 'vision_answers', subject: 'subject', value: (usr) => usr, file: 'answers.json', erase: 'delete' },
@@ -13,6 +13,7 @@ const rooms = require('openvibe-shared/rooms');
@@ -13,6 +13,7 @@ const rooms = require('openvibe-shared/rooms');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { render, esc } = require('./html');
const { render, esc } = require('./html');
+const { isStaff } = require('../identity/staff');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Vision';
const SITE_NAME = 'OpenVibe.Vision';
@@ -50,10 +51,13 @@ function renderPage(o) {
@@ -50,10 +51,13 @@ function renderPage(o) {
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
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 = {
const nav = {
service: 'vision',
service: 'vision',
apiBase: NETWORK_URL,
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 },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -80,7 +84,7 @@ function renderPage(o) {
@@ -80,7 +84,7 @@ function renderPage(o) {
robots: o.index ? 'index, follow' : 'noindex, nofollow',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
- navLinks: NAV,
+ navLinks: links,
navbar: nav,
navbar: nav,
footer,
footer,
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
head: `<meta name="referrer" content="strict-origin-when-cross-origin">
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
* GET /api/v1/benchmarks aggregates these rows without subjects: per provider/model, task, samples, successful
* 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.
* 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 iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
@@ -183,7 +185,115 @@ function createStore(s) {
@@ -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 };
module.exports = { createStore, wireAnswer, wireSample, wireUiCheck };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,311 @@
@@ -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,
+};
@@ -47,7 +47,10 @@ async function startNetworkStub() {
@@ -47,7 +47,10 @@ async function startNetworkStub() {
try {
try {
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: TABLES,
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
@@ -62,6 +65,8 @@ async function startNetworkStub() {
@@ -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 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 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 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 () => {
await check('an export carries only A\'s answers, compare samples and UI checks', async () => {
const body = bodyOf(exportEvent);
const body = bodyOf(exportEvent);
@@ -72,12 +77,15 @@ async function startNetworkStub() {
@@ -72,12 +77,15 @@ async function startNetworkStub() {
assert.ok(part, 'the part was pushed to Network');
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_vision');
assert.strictEqual(part.auth, 'Bearer tok_vision');
assert.strictEqual(part.body.subject, A);
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);
const all = JSON.stringify(part.body);
assert.ok(all.includes(aAnswer.id));
assert.ok(all.includes(aAnswer.id));
assert.ok(all.includes(aCheck.id));
assert.ok(all.includes(aCheck.id));
assert.ok(all.includes(aCompare.answer.id));
assert.ok(all.includes(aCompare.answer.id));
+ assert.ok(all.includes(aReview.id));
assert.ok(!all.includes(bAnswer.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(bCheck.id));
assert.ok(!all.includes('B answer'));
assert.ok(!all.includes('B answer'));
const answers = part.body.files.find((f) => f.name === 'answers.json').content;
const answers = part.body.files.find((f) => f.name === 'answers.json').content;
@@ -94,6 +102,8 @@ async function startNetworkStub() {
@@ -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_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_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_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_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);
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`);
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
@@ -101,6 +111,7 @@ async function startNetworkStub() {
@@ -101,6 +111,7 @@ async function startNetworkStub() {
assert.ok(confirmation.body.erased.vision_answers >= 1);
assert.ok(confirmation.body.erased.vision_answers >= 1);
assert.ok(confirmation.body.erased.vision_ui_checks >= 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_compare_samples >= 1);
+ assert.ok(confirmation.body.erased.vision_image_reviews >= 1);
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
});
});
@@ -46,7 +46,7 @@ async function boot(opts = {}) {
@@ -46,7 +46,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,215 @@
@@ -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&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); });