openvibers/openvibe.vision Commits
Vision faces with consent: detect and blur, verify yourself, find yourself, cast tags, library people (#2)
Changes
@@@ -59,6 +59,16 @@ VISION_TESSERACT=tesseract#VISION_IMAGE_TIMEOUT_MS=10000#VISION_ALLOW_PRIVATE_URLS=0++# Local face models live in DATA_DIR/models. Run npm run models:faces to fetch pinned, hashed weights.++# Without weights/key, only face routes degrade; /api/ready reports the missing component.++# VISION_FACE_KEY must be a random 32-byte AES key encoded as base64. Never rotate it without re-encrypting stored rows.++DATA_DIR=++VISION_FACE_KEY=++# Comma-separated region codes; empty means library grouping is off everywhere.++VISION_FACE_LIBRARY_REGIONS=++#VISION_FACE_MATCH_THRESHOLD=0.363++#VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN=30++# The UI check: HTML, URL or screenshot. It never executes JavaScript and never fetches remote stylesheets.#VISION_UI_MAX_HTML_KB=1024#VISION_UI_TIMEOUT_MS=10000@@@ -20,18 +20,35 @@ The first version: see with the best model for the job.estimates boxes and inline CSS to find overflow, cut-off text, WCAG contrast failures, overlapping positionedboxes and layering faults (`z-index` without position, a full-viewport fixed box). The screenshot pass uses thelocal OCR boxes and sharp pixels. Findings are data: kind, severity, selector, detail, evidence. Design's Arena-- calls it with `vision.ui_check`.++ calls it with `vision.ui.check`.- **The AI gateway.** Vision calls OpenVibe.AI's OpenAI-compatible endpoint with a first-party service token(`ai.run.create`, namespace `ai.*`). AI's routing, quotas, cache, provenance and cost accounting apply. Vision holdsno provider key, adds no provider and calls no paid provider directly.--- **No looks, no faces.** Public AI text goes through `noLooks`; an answer that names a person's looks is replaced by-- a neutral sentence. Face recognition is not implemented and is not called, ever.--- **Privacy.** No image bytes are stored: an answer keeps a sha256, the media type, the cleaned text and the model-- provenance. Answers, compare samples and UI checks are exported and erased with the person's account.++- **No looks in AI text.** Public AI text goes through `noLooks`; an answer that names a person's looks is replaced by++ a neutral sentence.++- **Faces, by purpose, with signed consent.** `POST /api/v1/faces/detect` and `/blur` are open local YuNet privacy++ tools with no templates or tracking. Self verification, self search, adult cast tagging and private library++ grouping each need their own signed consent (`face_consents`), a local SFace template encrypted with++ `VISION_FACE_KEY` (AES-256-GCM), and immediate erasure on opt-out. A cast matcher reads only the named channel's++ roster and revoking deletes past tags. Library grouping is region-gated and unknown faces stay unlabelled.++ `POST /api/v1/faces/verify` returns a score, threshold and `liveness: "not_checked"`, never an identity. The daily++ retention job (off in `VISION_DRILL`) expires templates three years after last use. `npm run models:faces` pins the++ YuNet and SFace downloads by URL and SHA-256. Pages: `/people` and `/people/privacy`; `/privacy` stays the shared++ legal policy.++- **Network tools, wave 1.** `POST /api/v1/alt` (routed editable alt text), `POST /api/v1/index` and++ `GET`/`POST /api/v1/find` (owner-scoped CLIP search), `POST /api/v1/receipt` (local Tesseract first),++ `POST /api/v1/product` (CLIP vector plus configured offers), `POST /api/v1/watch` (DETR boxes and scene change)++ and `POST /api/v1/moments` (scene histogram plus CLIP prompts). Each answer carries a receipt. `npm run++ models:fetch` downloads pinned quantized CLIP and DETR weights; missing weights degrade to an honest 503.++- **Privacy.** General answers store no image bytes: an answer keeps a sha256, the media type, the cleaned text and++ the model provenance. Opt-in face uploads, vectors and templates are encrypted. Answers, compare samples, UI checks++ and every face row are exported and erased with the person's account.- **The service.** Network sign-in as OAuth client `vision` (PKCE S256, `vision_at`/`vision_rt`/`vision_oauth`),truthful readiness, Prometheus metrics on loopback, discovery, a systemd unit, an nginx vhost and CI. Pages areserver-rendered with stable `vi-` ids/classes; `public/css/vision.css` and `public/js/vision.js` areone-comment placeholders for the main session.- **Tests.** Migration, SSO, API, OCR, compare/benchmarks, the UI check (including a planted overflow), noLooks,-- pages/cache and account export/deletion, each in its own process on a temporary PGlite database with a mock-- Network, a mock OpenVibe.AI and local image/page fixtures.++ pages/cache, account export/deletion, faces with a fake ONNX model (consent gates, roster-only matching,++ encryption at rest, opt-out, retention, region gate and export/deletion), and the network tools with fake CLIP,++ DETR and OCR, each in its own process on a temporary PGlite database with a mock Network, a mock OpenVibe.AI and++ local image/page fixtures.@@@ -2,7 +2,7 @@> See with the best model for the job.--**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Status:** v1, the §5.8b faces work and the network tools written and tested (see [STATUS.json](STATUS.json)).**Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION`**License:** AGPL-3.0-only (same as every OpenVibe service).@@@ -10,7 +10,7 @@ OpenVibe.Vision answers questions about a picture. The cheapest engine goes firsanswers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be runthrough the local reader and up to three AI model hints side by side, with the model that actually answered, itscost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns--overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No face recognition, ever.**++overflow, cut-off text, low contrast, overlap and layering faults as JSON. Face tools run locally; identifying matches require separate, purpose-scoped consent.## v1 scope (plan §5.8)@@@ -23,13 +23,31 @@ overflow, cut-off text, low contrast, overlap and layering faults as JSON. **Nocost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text.- **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding`vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously.--- **No faces, no looks.** No face recognition is implemented or called; the prompt also forbids naming or identifying a person. Public AI text is passed through `noLooks`;++- **Consent-scoped faces, no looks in AI text.** Face detection and blur are open privacy tools. Matching requires a separate signed consent for each purpose. Public AI text is passed through `noLooks`;an answer that names a person's looks is replaced by a neutral sentence.--- **Privacy.** No image bytes are stored: a sha256, media type and byte count are. Answers, compare samples and UI-- checks are exported and erased with the person's account.++- **Privacy.** General answers store no image bytes: a sha256, media type and byte count are stored. Opt-in face uploads, vectors and templates are encrypted and private. Answers, compare samples, UI checks and every face row are exported and erased with the person's account.- **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has nopublic records, so it writes no Search document and has no outbox.++## Faces with consent (§5.8b)++++`npm run models:faces` downloads pinned OpenCV Zoo YuNet and SFace weights into `DATA_DIR/models`, checking a SHA-256 before installation. They are not in git. YuNet's directory is MIT licensed; SFace's directory is Apache 2.0 licensed. **Open question before production distribution:** [SFace weight and training-data provenance](https://github.com/opencv/opencv_zoo/issues/318) needs upstream clarification. The service starts without weights or `VISION_FACE_KEY`; `/api/ready` reports an optional degraded face check. Detect/blur need only YuNet; enrolment, verification and cast matching also need SFace and the key, and answer 503 until they exist. All face inference uses `onnxruntime-node` on this host, never a cloud model.++++The key is `VISION_FACE_KEY` (base64 encoding of 32 random bytes). AES-256-GCM encrypts templates, Vision uploads and face vectors under the person's Network subject. Losing this key makes stored face data unreadable; rotate it only with a re-encryption migration. `VISION_FACE_MATCH_THRESHOLD` defaults to the OpenCV SFace cosine example threshold 0.363 and needs calibration before consequential use. `VISION_FACE_LIBRARY_REGIONS` is a comma-separated allowlist; empty disables library grouping everywhere. `VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN` limits anonymous detect/blur, alongside the API address limit. No face template enters Search, model prompts or Bench.++++The four signed text versions are exposed by `GET /api/v1/faces/consents/text`. A Network person signs one purpose at a time by sending `signed_name` and the current `text_version` to `POST /api/v1/faces/consents/:feature` (`verify`, `find`, `cast`, `library`). Cast also needs `adult: true` and a named `channel`; library needs an allowed `region`. The signed name and server time are kept in `face_consents`. `DELETE /api/v1/faces/consents/:feature` revokes and immediately deletes that purpose's template; cast revocation also removes past tags. `/people` has the same choices and `/people/privacy` explains face retention. The site's shared legal privacy policy remains at `/privacy`.++++| Route | Result and scope |++|---|---|++| `POST /api/v1/faces/detect`, `/blur` | Anyone; boxes/count or image/png redaction, no templates or tracking. |++| `POST /api/v1/faces/enrol/:feature` | Network person only; one face image after that feature's consent (`verify`, `find`, `cast`). |++| `POST /api/v1/faces/verify` | Person only; `{score,match,threshold,liveness:"not_checked"}` against their own verify template. A match alone must never unlock anything: use a tested liveness component, second factor and non-biometric route before any consequential use. |++| `POST /api/v1/faces/media`, `GET /api/v1/faces/media/:id`, `POST /api/v1/faces/find` | Person only; encrypted own Vision uploads, owner-only links and self-search matches. Media objects are deferred until a source-owner grant and open-time access check exist. |++| `POST /api/v1/faces/cast/match` | Live service token with `vision.face.cast.match` and signed `channel_id` claim; compares only that channel's enrolled roster. Requires on-stream notice from Live. Person can inspect their own tags at `GET /api/v1/faces/cast/tags`. |++| `POST /api/v1/faces/library/cluster`, `GET /api/v1/faces/library/groups`, `POST /api/v1/faces/library/correct` | Person only, separate opt-in and allowed region. Unknown groups have no label. Corrections: `exclude`, `merge`, `split`; switching off erases groups. |++++`face_consents`, `face_templates`, `face_media`, `face_tags` and `face_clusters` are created by `migrations/0003_faces.sql`. A daily job (off in `VISION_DRILL`) deletes inactive templates at three years since last use and revoked consent audit rows within one year of purpose end. ADR-033 export and account deletion include all five tables. The account export carries encrypted template and upload ciphertext; it does not expose the server key.++## What runs where| Step | Where | Cost | Leaves the host? |@@@ -38,6 +56,7 @@ overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No| OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no || UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) || UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no |++| Face detect, blur and consented matching | this host, YuNet + SFace + ONNX Runtime | 0 | no || Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI || OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent || Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI |@@@ -54,8 +73,13 @@ outbound capability; AI's quotas and its own route decide whether a run is allow| `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `0001_initial.sql` || `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` || `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |++| `face_consents` | subject, feature `verify\|find\|cast\|library`, channel scope, signed text version/name, granted_at, revoked_at, last_used_at, purpose_ends_at | `migrations/0003_faces.sql` |++| `face_templates` | subject, feature, scope, AES-GCM ciphertext of the SFace vector, created/last_used times | `0003_faces.sql` |++| `face_media` | owner, AES-GCM ciphertext of an opt-in Vision upload and its face vectors, media type | `0003_faces.sql` |++| `face_tags` | consenting subject, channel, private media reference and time; removed on cast revocation | `0003_faces.sql` |++| `face_clusters` | owner, media, encrypted vector, group id, optional label (null while unknown), excluded flag | `0003_faces.sql` |--No table stores image bytes, a token or a provider key.++General answer tables store no image bytes, token or provider key. The opt-in face library stores encrypted uploads and vectors.## Does not own@@@ -68,7 +92,7 @@ No table stores image bytes, a token or a provider key.## Capabilities and grants--The service offers four capabilities. The main session registers their manifests and grants them; v1 checks the++The service offers eleven capabilities. The main session registers their manifests and grants them; v1 checks thegrant list directly, so a token that already carries one works.| Capability | Routes | Typical caller |@@@ -77,6 +101,13 @@ grant list directly, so a token that already carries one works.| `vision.ocr` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person || `vision.compare` | `POST /api/v1/compare` | a first-party service or a person || `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person |++| `vision.face.cast.match` | `POST /api/v1/faces/cast/match` (a signed `channel_id` claim must match the request) | **OpenVibe.Live**, or a person inspecting their own tags |++| `vision.alt` | `POST /api/v1/alt` | Pics, Media and Chat |++| `vision.find` | `POST /api/v1/index`, `GET`/`POST /api/v1/find` (owner user token) | Media and Search |++| `vision.receipt` | `POST /api/v1/receipt` | Food |++| `vision.product` | `POST /api/v1/product` | Deals |++| `vision.watch` | `POST /api/v1/watch` (owner user token) | Cam |++| `vision.moments` | `POST /api/v1/moments` (owner user token) | Live and Media |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.@@@ -125,7 +156,10 @@ Answer (abridged):Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mode`, `vision.question_required`,`vision.image_required`, `vision.image_too_large`, `vision.image_private_url`, `vision.image_unsupported`,--`vision.ocr_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`.++`vision.ocr_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`, and the face codes++`faces.consent_required`, `faces.signature_required`, `faces.adult_required`, `faces.region_disabled`,++`faces.enrolment_required`, `faces.one_face_required`, `faces.model_unavailable`, `faces.key_unavailable`,++`faces.live_grant_required`.## Pages@@@ -142,6 +176,12 @@ person is `private, no-store`.| `/ui-check` | HTML, URL or screenshot; inline for a guest, saved at `/ui/:id` for a person || `/a/:id` | one saved answer (owner or staff) || `/ui/:id` | one saved UI check (owner or staff) |++| `/find` | private visual search over a person's own indexed images |++| `/read` | the receipt reader |++| `/watch` | watched objects and scene change in a camera still |++| `/people` | the four face purposes, each with its own signed switch, enrolment and off control |++| `/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 || 404 | the not-found page |@@@ -170,7 +210,8 @@ node scripts/proof.js # local OCR + a planted UI overflow, no network andThe tests use a mock Network, a mock OpenVibe.AI and local image/page fixtures: migration, SSO and readiness, theAPI and its capability guards, local OCR and its fallback, compare and benchmark aggregation, the UI check (aplanted overflow, cut-off line, low contrast, overlap and layering fault), noLooks, pages/cache, account--export/deletion and the image SSRF guard.++export/deletion, the image SSRF guard, and faces with a fake ONNX model (all four consent gates, opt-out erasure,++cast-roster-only matching, AES-GCM at rest, the retention job, the region gate and ADR-033 export/deletion).## Deploy@@@ -25,6 +25,9 @@ domains) and every repository under github.com/OpenVibers. Machine-readable cont- UI checks: source (html, url or screenshot), the viewport, the findings and the summary. Never the screenshot.- A person's Network session tokens only in httpOnly cookies (`vision_at`, `vision_rt`); no token is stored in thedatabase, rendered into a page or logged.++- Face data, only after a separate consent: AES-256-GCM ciphertext of face templates, opt-in Vision uploads and++ vectors; the signed consent record (typed name, text version and time), cast tags and private library groups.++ Never sent to OpenVibe.AI, Search, model prompts or Bench.- No provider key. AI calls use the service's own Network client-credentials token and OpenVibe.AI's routing.## Image inputs and the UI check@@@ -39,11 +42,9 @@ The `/internal/events` account route is loopback only and refuses a proxied requ`/internal` and `/metrics`. A service token is judged on its own signature, audience and capability grant; anunknown or forged token is refused, never downgraded to the session.--## No faces++## Faces with scoped consent--Vision never identifies or recognises faces, never names a person, and never describes a person's looks in public AI text. The model prompt--carries the rule and every AI answer is passed through `noLooks`; an answer that names looks is replaced. A report--that either rule was broken is a security report for this repository.++Detect and blur do not identify anyone. Face matching uses local YuNet/SFace ONNX models and separately signed, purpose-specific consent. Templates and opt-in uploads are encrypted with `VISION_FACE_KEY` (AES-256-GCM) and private; a person can withdraw a purpose and erase its template immediately. A cast matcher reads only the named channel's consenting roster, and revoking a grant stops matches and deletes past tags. Library grouping is off unless `VISION_FACE_LIBRARY_REGIONS` allows the region. Vision never identifies strangers on the open web, builds no public face index, and a match alone never unlocks anything (liveness is `not_checked` until a tested component exists). Public AI text never describes a person's looks: the model prompt carries the rule and every AI answer is passed through `noLooks`. A report that any rule was broken is a security report for this repository.## Supported versions@@@ -4,8 +4,8 @@"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",-- "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp",++ "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; 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.229.0",@@@ -20,14 +20,21 @@"vision.ask","vision.ocr","vision.compare",-- "vision.ui_check"++ "vision.ui.check",++ "vision.face.cast.match",++ "vision.alt",++ "vision.find",++ "vision.receipt",++ "vision.product",++ "vision.watch",++ "vision.moments"],"outboundCapabilities": ["ai.run.create"],"capabilitiesRegistered": false,"serviceManifestRegistered": false,-- "summary": "OpenVibe.Vision v1 sees with the best model for the job. A read question goes to the local Tesseract reader first; if the local engine cannot answer, OpenVibe.AI's routed vision model does, and every answer names the model, provider, route, cost and latency and says why it was chosen. A compare runs the same image through the local reader and up to three AI model hints side by side. The UI check reads HTML, a public URL or a screenshot locally and returns overflow, cut-off text, low contrast, overlap and layering findings as JSON; Design's Arena can call POST /api/v1/ui-check with a service token holding vision.ui_check. No image bytes are stored, no provider key exists here, and no face recognition is ever used.",++ "summary": "OpenVibe.Vision reads images and checks UI faults, and its network tools produce alt text, owner-scoped private image search, receipt lines, product matches, camera detections and moments. Local face detection and blur use no identity; personal matching requires separately signed, purpose-scoped consent. General answers keep no image bytes. Opt-in face uploads and templates are encrypted, private and deletable. AI text never describes a person's looks.","works": ["Ask a picture: multipart upload, data URL or public image URL; task ask/describe/identify/count/translate/ocr; auto/local/ai modes","Local-first OCR: Tesseract 5 on this host, word boxes and mean confidence, zero cost, an optional AI fallback only after an empty local pass",@@@ -35,8 +42,11 @@"Compare: local OCR plus one AI run per model hint, requested vs answered model, measured latency and AI cost, sample rows","UI check: static HTML/CSS pass (overflow, cut-off, contrast, overlap, layering), public URL pass with SSRF guard, screenshot pass through OCR boxes and sharp pixels","No looks: public AI text is passed through noLooks; an answer that names looks is replaced by a neutral sentence",-- "No face recognition: not implemented, not called, stated on the pages",-- "Answers, compare samples and UI checks stored without image bytes; account export/deletion over all three",++ "Local face detection and blur through YuNet without templates or tracking; detect/blur are open and rate-limited",++ "Consent-scoped SFace matching: verify (1:1), self search in own Vision uploads, adult cast rosters and region-gated private library grouping",++ "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",++ "Answers, compare samples, private index and UI checks stored without image bytes; account export/deletion over every table including faces and the private index","Network sign-in as OAuth client vision (PKCE S256, vision_at/vision_rt/vision_oauth), truthful readiness, loopback metrics","Server-rendered pages with stable vi- ids/classes, discovery artifacts, deploy unit and nginx vhost, CI"],@@@ -47,6 +57,8 @@"A real browser-rendered UI pass (JavaScript execution, getBoundingClientRect, computed styles); v1 is a documented static pass plus a screenshot pass","Search documents/outbox: v1 has no public records to index","A per-person history page; saved answers are reachable by id for their owner and staff",++ "A tested liveness / presentation-attack detector: verify and cast enrolment return liveness \"not_checked\"",++ "Media-object search through a source-owner grant: find yourself currently searches only the person's Vision uploads","The capability manifests and grants; the main session registers vision.* and grants them"],"deployed": false,@@@ -56,7 +68,8 @@"OpenVibe.AI routed answers","model compare with cost and latency","UI check: overflow, cut-off, contrast, overlap, layering",-- "noLooks and no face recognition",++ "noLooks and consent-scoped faces",++ "network tools: alt, index/find, receipt, product, watch, moments","account export and deletion","Network sign-in","server-rendered pages",nnew file mode 100644@@@ -0,0 +1,64 @@++-- phase: expand++-- Face data stays private to a Network subject and never enters Search, prompts or Bench.++CREATE TABLE face_consents (++ id text PRIMARY KEY,++ subject text NOT NULL,++ feature text NOT NULL CHECK (feature IN ('verify', 'find', 'cast', 'library')),++ scope text NOT NULL DEFAULT '',++ text_version text NOT NULL,++ signature text NOT NULL,++ granted_at timestamptz NOT NULL,++ revoked_at timestamptz,++ last_used_at timestamptz NOT NULL,++ purpose_ends_at timestamptz,++ CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CHECK (length(scope) <= 160),++ CHECK (length(signature) BETWEEN 1 AND 160)++);++CREATE UNIQUE INDEX face_consents_active ON face_consents (subject, feature, scope) WHERE revoked_at IS NULL;++CREATE INDEX face_consents_retention ON face_consents (last_used_at, purpose_ends_at);++++CREATE TABLE face_templates (++ subject text NOT NULL,++ feature text NOT NULL,++ scope text NOT NULL DEFAULT '',++ ciphertext text NOT NULL,++ created_at timestamptz NOT NULL,++ last_used_at timestamptz NOT NULL,++ PRIMARY KEY (subject, feature, scope)++);++-- A separate table holds only this person's own Vision uploads and encrypted face vectors.++CREATE TABLE face_media (++ id text PRIMARY KEY,++ subject text NOT NULL,++ image_ciphertext text NOT NULL,++ image_mime text NOT NULL,++ vectors_ciphertext text NOT NULL,++ created_at timestamptz NOT NULL,++ last_used_at timestamptz NOT NULL++);++CREATE INDEX face_media_owner ON face_media (subject, created_at DESC);++++CREATE TABLE face_tags (++ id text PRIMARY KEY,++ subject text NOT NULL,++ channel text NOT NULL,++ media_ref text NOT NULL,++ created_at timestamptz NOT NULL,++ last_used_at timestamptz NOT NULL++);++CREATE INDEX face_tags_channel ON face_tags (channel, created_at DESC);++CREATE INDEX face_tags_subject ON face_tags (subject, channel);++++CREATE TABLE face_clusters (++ id text PRIMARY KEY,++ subject text NOT NULL,++ media_id text NOT NULL REFERENCES face_media (id) ON DELETE CASCADE,++ vector_ciphertext text NOT NULL,++ group_id text NOT NULL,++ label text,++ excluded boolean NOT NULL DEFAULT false,++ created_at timestamptz NOT NULL,++ last_used_at timestamptz NOT NULL++);++CREATE INDEX face_clusters_owner ON face_clusters (subject, created_at DESC);@@@ -17,6 +17,7 @@"helmet": "^8.1.0","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.235.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",@@@ -465,6 +466,73 @@"url": "https://opencollective.com/libvips"}},++ "node_modules/@huggingface/transformers/node_modules/global-agent": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",++ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "boolean": "^3.0.1",++ "es6-error": "^4.1.1",++ "matcher": "^3.0.0",++ "roarr": "^2.15.3",++ "semver": "^7.3.2",++ "serialize-error": "^7.0.1"++ },++ "engines": {++ "node": ">=10.0"++ }++ },++ "node_modules/@huggingface/transformers/node_modules/matcher": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",++ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",++ "license": "MIT",++ "dependencies": {++ "escape-string-regexp": "^4.0.0"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": {++ "version": "1.21.0",++ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",++ "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",++ "license": "MIT"++ },++ "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": {++ "version": "1.21.0",++ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",++ "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",++ "hasInstallScript": true,++ "license": "MIT",++ "os": [++ "win32",++ "darwin",++ "linux"++ ],++ "dependencies": {++ "global-agent": "^3.0.0",++ "onnxruntime-common": "1.21.0",++ "tar": "^7.0.1"++ }++ },++ "node_modules/@huggingface/transformers/node_modules/serialize-error": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",++ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",++ "license": "MIT",++ "dependencies": {++ "type-fest": "^0.13.1"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },"node_modules/@huggingface/transformers/node_modules/sharp": {"version": "0.34.5","resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",@@@ -509,6 +577,18 @@"@img/sharp-win32-x64": "0.34.5"}},++ "node_modules/@huggingface/transformers/node_modules/type-fest": {++ "version": "0.13.1",++ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",++ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",++ "license": "(MIT OR CC0-1.0)",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },"node_modules/@img/colour": {"version": "1.1.0","resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",@@@ -1119,6 +1199,15 @@"node": ">= 0.6"}},++ "node_modules/adm-zip": {++ "version": "0.6.1",++ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",++ "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",++ "license": "MIT",++ "engines": {++ "node": ">=14.0"++ }++ },"node_modules/ajv": {"version": "8.20.0","resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",@@@ -1729,17 +1818,15 @@}},"node_modules/global-agent": {-- "version": "3.0.0",-- "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",-- "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",++ "version": "4.1.6",++ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.6.tgz",++ "integrity": "sha512-V9u791atI/bS7RryfRTK2oixDWo6/RCrIikjwuQ4kADPRZZml1C6LUJXc7y6qRhaAF8geo3bIvE/siAGJCw38A==","license": "BSD-3-Clause","dependencies": {-- "boolean": "^3.0.1",-- "es6-error": "^4.1.1",-- "matcher": "^3.0.0",-- "roarr": "^2.15.3",-- "semver": "^7.3.2",-- "serialize-error": "^7.0.1"++ "globalthis": "^1.0.2",++ "matcher": "^4.0.0",++ "semver": "^7.3.5",++ "serialize-error": "^8.1.0"},"engines": {"node": ">=10.0"@@@ -2045,15 +2132,18 @@"license": "Apache-2.0"},"node_modules/matcher": {-- "version": "3.0.0",-- "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",-- "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",++ "version": "4.0.0",++ "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",++ "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==","license": "MIT","dependencies": {"escape-string-regexp": "^4.0.0"},"engines": {"node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"}},"node_modules/math-intrinsics": {@@@ -2217,15 +2307,15 @@}},"node_modules/onnxruntime-common": {-- "version": "1.21.0",-- "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",-- "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",++ "version": "1.31.1",++ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.31.1.tgz",++ "integrity": "sha512-F+nnYNiQtmNJ95V6R/isBJqiVpdANCrsMEg1OaF+Bkbl3GAtzWLR7Ng17Ld0QtYtYJyotOHPJzOQn1Y6PLZjpg==","license": "MIT"},"node_modules/onnxruntime-node": {-- "version": "1.21.0",-- "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",-- "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",++ "version": "1.31.1",++ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.31.1.tgz",++ "integrity": "sha512-ijnSghbDHVWaQ2nDPmVccPlCHI8kH1Mx1aJ4a/qzRP16bBcVs4E3EqZQKZCkhWnbqN1tuQWxagimFtDpbPYYQw==","hasInstallScript": true,"license": "MIT","os": [@@@ -2234,9 +2324,9 @@"linux"],"dependencies": {-- "global-agent": "^3.0.0",-- "onnxruntime-common": "1.21.0",-- "tar": "^7.0.1"++ "adm-zip": "^0.6.0",++ "global-agent": "^4.1.3",++ "onnxruntime-common": "1.31.1"}},"node_modules/onnxruntime-web": {@@@ -2666,12 +2756,12 @@"license": "MIT"},"node_modules/serialize-error": {-- "version": "7.0.1",-- "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",-- "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",++ "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==","license": "MIT","dependencies": {-- "type-fest": "^0.13.1"++ "type-fest": "^0.20.2"},"engines": {"node": ">=10"@@@ -2892,9 +2982,9 @@"optional": true},"node_modules/type-fest": {-- "version": "0.13.1",-- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",-- "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",++ "version": "0.20.2",++ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",++ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==","license": "(MIT OR CC0-1.0)","engines": {"node": ">=10"@@@ -10,6 +10,7 @@"proof": "node scripts/proof.js","test": "node test/run.js","test:pg": "VISION_TEST_STORE=pg node test/run.js",++ "models:faces": "node scripts/download-face-models.js","models:fetch": "node scripts/fetch-models.js"},"keywords": [@@@ -26,6 +27,7 @@"helmet": "^8.1.0","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.235.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++// Pinned OpenCV Zoo assets. Download to a temporary file, verify SHA-256, then rename into DATA_DIR/models.++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const { modelsDir } = require('../server/paths');++++const MODELS = Object.freeze([++ {++ name: 'face_detection_yunet_2023mar.onnx',++ url: 'https://media.githubusercontent.com/media/opencv/opencv_zoo/f12e12798e8314f7c074a6656816c048dcc95b7a/models/face_detection_yunet/face_detection_yunet_2023mar.onnx',++ sha256: '8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4',++ },++ {++ name: 'face_recognition_sface_2021dec.onnx',++ url: 'https://media.githubusercontent.com/media/opencv/opencv_zoo/ba91a3b91d00d76e86540d4013f944bd6b514e39/models/face_recognition_sface/face_recognition_sface_2021dec.onnx',++ sha256: '0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79',++ },++]);++async function digest(file) {++ const h = crypto.createHash('sha256');++ for await (const chunk of fs.createReadStream(file)) h.update(chunk);++ return h.digest('hex');++}++async function download(model, dir = modelsDir()) {++ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });++ const target = path.join(dir, model.name);++ if (fs.existsSync(target) && await digest(target) === model.sha256) return target;++ const temp = path.join(dir, `.${model.name}.${process.pid}.tmp`);++ try {++ const response = await fetch(model.url, { signal: AbortSignal.timeout(120_000) });++ if (!response.ok) throw new Error(`${model.name}: download failed (${response.status})`);++ const file = fs.createWriteStream(temp, { mode: 0o600 });++ for await (const chunk of response.body) {++ if (!file.write(chunk)) await new Promise((resolve) => file.once('drain', resolve));++ }++ await new Promise((resolve, reject) => file.end((err) => err ? reject(err) : resolve()));++ if (await digest(temp) !== model.sha256) throw new Error(`${model.name}: sha256 mismatch`);++ fs.renameSync(temp, target);++ return target;++ } finally { fs.rmSync(temp, { force: true }); }++}++if (require.main === module) Promise.all(MODELS.map((m) => download(m))).then((paths) => {++ for (const file of paths) process.stdout.write(`verified ${file}\n`);++}).catch((err) => { console.error(err.message); process.exitCode = 1; });++module.exports = { MODELS, download, digest };@@@ -4,14 +4,15 @@* 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, /developers the pages (http/pages.js)-- * /api/v1/* the API (http/api.js)++ * /, /ask, /ocr, /compare, /ui-check, /a/:id, /ui/:id, /people, /people/privacy, /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)* /internal/events account export/deletion (loopback only)*-- * Every model call goes through OpenVibe.AI; the local OCR engine and the UI check run on this host. No provider-- * key, no paid provider and no image bytes are stored.++ * Every model call goes through OpenVibe.AI; the local OCR engine, the UI check and the local face models run on this++ * host. No provider key, no paid provider, and no general answer stores image bytes. Only the opt-in face library++ * keeps encrypted uploads.*/const path = require('path');const express = require('express');@@@ -27,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');++const { createFaceApi } = require('./http/faces-api');const { createPageRoutes } = require('./http/pages');const { createServiceReadiness } = require('./observability');const { assetVersion, send } = require('./render/layout');@@@ -37,6 +39,8 @@ const { createStore } = require('./store');const { createOcr } = require('./vision/ocr');const { createAi } = require('./vision/ai');const { createVision } = require('./vision/service');++const { createFacesModel } = require('./vision/faces-model');++const { createFaces } = require('./vision/faces');const { createNetworkSender } = require('openvibe-sdk/account-data');const PUBLIC_DIR = path.join(__dirname, '..', 'public');@@@ -62,6 +66,7 @@ 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 faces = createFaces({ s, config, model: opts.facesModel || createFacesModel({ dir: config.faces.modelsDir }) });const models = require('./vision/local-models').createLocalModels({ config, clip: opts.clip, detector: opts.detector });const networkTools = require('./vision/network-tools').createNetworkTools({ s, ai, ocr, models, config, fetchImpl });@@@ -72,7 +77,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Vision cannot answer account events'); });-- const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, models, networkTools, accountData, accountSend, log, fetchImpl };++ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, models, networkTools, accountData, accountSend, log, fetchImpl };const app = express();app.disable('x-powered-by');@@@ -115,7 +120,7 @@ async function createApp(opts = {}) {// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-vision', name: 'OpenVibe.Vision', version: VERSION }));release.mount(app, { registry: metrics.registry });-- const readiness = createServiceReadiness({ s, config, valkey, ai, ocr, models });++ const readiness = createServiceReadiness({ s, config, valkey, ai, ocr, faces, models });app.get('/api/ready', readiness.handler);// ── Who is asking (verified offline; refreshed when expired) ──@@@ -149,6 +154,7 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.VISION_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ app.use('/api/v1/faces', createFaceApi(ctx));app.use('/api/v1', createApi(ctx));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));@@@ -8,6 +8,7 @@*/require('dotenv').config();const trim = (s) => String(s || '').replace(/\/+$/, '');++const { modelsDir } = require('./paths');const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);function load(env = process.env) {@@@ -96,6 +97,16 @@ function load(env = process.env) {timeoutMs: Math.max(1000, int(env.VISION_IMAGE_TIMEOUT_MS, 10000)),allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',},++ faces: {++ modelsDir: modelsDir(env),++ key: env.VISION_FACE_KEY || '',++ allowedRegions: String(env.VISION_FACE_LIBRARY_REGIONS || '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),++ threshold: (() => {++ const raw = String(env.VISION_FACE_MATCH_THRESHOLD || '').trim();++ const n = raw ? Number(raw) : 0.363;++ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0.363;++ })(),++ },// The UI check: a static layout pass over HTML or a fetched page (no JavaScript executed), plus a// screenshot path through the local OCR boxes and sharp pixel sampling. Nothing leaves this host.@@@ -2,8 +2,9 @@/*** OpenVibe.Vision's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.-- * Nothing written here is ever a secret: an answer stores the cleaned text, a sha256 of the image and the model-- * provenance, never the image bytes, a token or a provider key.++ * A general answer stores the cleaned text, a sha256 of the image and the model provenance, never the image bytes,++ * a token or a provider key. The opt-in face tables hold encrypted templates/uploads and consent records, never the++ * AES key.*/const fs = require('fs');const path = require('path');@@@ -13,7 +13,7 @@ const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Vision';--const DESCRIPTION = 'OpenVibe.Vision sees with the best model for the job: ask a picture, read its text with a local OCR engine first, compare models side by side through OpenVibe.AI, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.';++const DESCRIPTION = 'OpenVibe.Vision reads images and checks pages. Local face detection and blur are open; identifying matches require separate, purpose-scoped consent.';const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/a/', '/ui/'];function dayOf(ts) {@@@ -40,6 +40,9 @@ function publicPages() {{ path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },{ path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },{ path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },++ { path: '/people', changefreq: 'weekly', priority: 0.6, title: 'People and consent', text: 'Separate choices for self verification, self search, cast tagging and private library grouping.' },++ { path: '/privacy', changefreq: 'yearly', priority: 0.3, title: 'Privacy policy', text: 'The privacy policy for OpenVibe.Vision.' },++ { path: '/people/privacy', changefreq: 'weekly', priority: 0.6, title: 'Face privacy', text: 'Local processing, private links, retention, erasure and prohibited uses.' },{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Vision API', text: 'The JSON API, the capabilities other services need, and what leaves this host.' },];}@@@ -61,8 +64,8 @@ function createDiscoveryRoutes(ctx) {r.get('/llms.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({name: SITE_NAME,-- summary: 'OpenVibe.Vision answers questions about a picture. The cheapest engine first: local Tesseract OCR answers a read question; otherwise OpenVibe.AI\'s routed vision model answers. A compare runs the local reader and AI models on the same image and reports each answer with its model, cost and latency. The UI check flags overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot. No face recognition, ever; public AI text never describes people\'s looks.',-- details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare or vision.ui.check.',++ summary: 'OpenVibe.Vision answers questions about a picture, reads text locally and checks UI faults. Local face detection and blur need no identity; self verification, self search, cast tagging and library grouping each require separate consent. Public AI text never describes people\'s looks.',++ details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. Face detection/blur are open at POST /api/v1/faces/detect and /blur; matching needs separate signed consent per purpose (verify, find, cast, library). A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare, vision.ui.check or vision.face.cast.match.',sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Vision', url: abs('/') },@@@ -70,6 +73,8 @@ function createDiscoveryRoutes(ctx) {{ title: 'Read text from a picture', url: abs('/ocr') },{ title: 'Compare models', url: abs('/compare') },{ title: 'The UI check', url: abs('/ui-check') },++ { title: 'People and consent', url: abs('/people') },++ { title: 'Face privacy', url: abs('/people/privacy') },{ title: 'The API', url: abs('/developers') },] },{ title: 'Machine-readable', links: [@@@ -91,14 +96,14 @@ function createDiscoveryRoutes(ctx) {r.get('/llms-full.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({site: SITE_NAME,-- summary: 'Every public page of OpenVibe.Vision: the home, ask, OCR, compare, the UI check and the API.',++ summary: 'Every public page of OpenVibe.Vision: the home, ask, OCR, compare, the UI check, people and consent, face privacy and the API.',base: site,maxBytes: 256 * 1024,sections: [{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },{ title: 'Rules', pages: [-- { url: '/developers', title: 'No face recognition', text: 'Vision never identifies or recognises faces and never describes a person\'s looks in public AI text.' },-- { url: '/developers', title: 'No stored images', text: 'Answers store a sha256, the media type and the cleaned text; never the image bytes.' },++ { url: '/people/privacy', title: 'Faces with consent', text: 'Face matching is local and limited to a separately consented purpose. Public AI text never describes a person\'s looks.' },++ { url: '/developers', title: 'No stored general images', text: 'General answers store a sha256, the media type and the cleaned text, never the image bytes. Only an opt-in face library keeps encrypted uploads.' },] },],}));nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++const express = require('express');++const multer = require('multer');++const rateLimit = require('express-rate-limit');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { loadImage } = require('../vision/images');++const { VisionError } = require('../vision/service');++++function createFaceApi({ config, principal, faces, fetchImpl }) {++ const r = asyncRouter();++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });++ const imageField = (req, res, next) => upload.single('image')(req, res, next);++ r.use(express.json({ limit: `${Math.ceil(config.images.maxMb * 1.5) + 2}mb` }));++ r.use((_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ r.use(principal.middleware);++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });++ const person = (req, res, next) => {++ if (req.principal.kind !== 'user') return problem(res, 401, 'faces.person_required', 'Sign in with your own Network account.');++ if (req.method !== 'GET' && req.principal.viaSession && !sameOrigin(req, config.baseUrl)) return problem(res, 403, 'vision.cross_site', 'This signed-in change must come from Vision.');++ return next();++ };++ async function image(req) {++ const b = req.body || {};++ return loadImage({ file: req.file && req.file.buffer, image: b.image, imageUrl: b.image_url, config, fetchImpl });++ }++ const run = (fn) => async (req, res) => {++ try { return res.json(await fn(req, res)); }++ catch (err) {++ if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail);++ if (err && /^image\./.test(err.code || '')) return problem(res, err.code === 'image.too_large' ? 413 : 400, `vision.${err.code.replace('.', '_')}`, err.message);++ throw err;++ }++ };++ const privacyLimit = rateLimit({++ windowMs: 60_000, limit: Number(process.env.VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN) || 30,++ standardHeaders: true, legacyHeaders: false,++ handler: (req, res) => problem(res, 429, 'vision.rate_limited', 'too many face privacy requests from this address; retry shortly'),++ });++ r.post('/detect', privacyLimit, imageField, run(async (req) => faces.detect((await image(req)).buffer)));++ r.post('/blur', privacyLimit, imageField, async (req, res) => {++ try {++ const out = await faces.blur((await image(req)).buffer);++ res.set('X-Faces-Blurred', String(out.count));++ return res.type('image/png').send(out.buffer);++ } catch (err) {++ if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail);++ if (err && /^image\./.test(err.code || '')) return problem(res, err.code === 'image.too_large' ? 413 : 400, `vision.${err.code.replace('.', '_')}`, err.message);++ throw err;++ }++ });++ r.get('/consents/text', (_req, res) => res.json(faces.TEXT));++ r.get('/consents', person, run((req) => faces.listConsents(req.principal.subject)));++ r.post('/consents/:feature', person, run((req) => faces.consent(req.principal.subject, req.params.feature, {++ signedName: req.body?.signed_name, textVersion: req.body?.text_version, channel: req.body?.channel,++ adult: req.body?.adult, region: req.body?.region,++ })));++ r.delete('/consents/:feature', person, run((req) => faces.revoke(req.principal.subject, req.params.feature, req.body?.channel || req.query.channel)));++ r.post('/enrol/:feature', person, imageField, run(async (req) => faces.enrol(req.principal.subject, req.params.feature, (await image(req)).buffer, req.body?.channel)));++ r.post('/verify', person, imageField, run(async (req) => faces.verify(req.principal.subject, (await image(req)).buffer)));++ r.post('/media', person, imageField, run(async (req) => { const im = await image(req); return faces.upload(req.principal.subject, im.buffer, im.mime); }));++ r.get('/media/:id', person, async (req, res) => {++ try { const im = await faces.media(req.principal.subject, req.params.id); return res.type(im.mime).send(im.buffer); }++ catch (err) { if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail); throw err; }++ });++ r.post('/find', person, run((req) => faces.find(req.principal.subject)));++ r.get('/cast/tags', person, run((req) => faces.tags(req.principal.subject)));++ r.post('/cast/match', imageField, async (req, res, next) => {++ const p = req.principal, channel = req.body?.channel;++ if (p.kind !== 'service' || p.sub !== 'svc:live' || !principal.granted(p, 'vision.face.cast.match') || p.claims.channel_id !== channel) {++ return problem(res, 403, 'faces.live_grant_required', 'A Live token scoped to this channel is required.');++ }++ return next();++ }, run(async (req) => faces.castMatch(req.body.channel, (await image(req)).buffer, req.body.media_ref)));++ r.post('/library/cluster', person, run((req) => faces.cluster(req.principal.subject, req.body?.region)));++ r.get('/library/groups', person, run((req) => faces.groups(req.principal.subject)));++ r.post('/library/correct', person, run((req) => faces.correction(req.principal.subject, req.body?.action, req.body?.ids)));++ // multer errors are translated without exposing file contents.++ r.use((err, req, res, next) => err && err.code === 'LIMIT_FILE_SIZE'++ ? problem(res, 413, 'vision.image_too_large', 'Image exceeds the configured size limit.') : next(err));++ return r;++}++module.exports = { createFaceApi };@@@ -12,6 +12,9 @@* /ui-check HTML, URL or screenshot (POST /ui-check → /ui/:id, or inline for a guest)* /a/:id one saved answer (owner or staff)* /ui/:id one saved UI check (owner or staff)++ * /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* /developers the API* 404*/@@@ -28,11 +31,12 @@ const { html, raw } = require('../render/html');const { send } = require('../render/layout');const { isStaff } = require('../identity/staff');const views = require('../views');++const peopleViews = require('../views/people');const PUBLIC_CACHE = 'public, max-age=60';function createPageRoutes(ctx) {-- const { config, s, store, vision, ai, ocr, fetchImpl } = ctx;++ const { config, s, store, vision, ai, ocr, faces, fetchImpl } = ctx;const r = asyncRouter();const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });r.use(express.urlencoded({ extended: false, limit: '2mb' }));@@@ -59,11 +63,35 @@ function createPageRoutes(ctx) {// ── Home ─────────────────────────────────────────────────r.get('/', (req, res) => page(req, res, {index: true, cache: PUBLIC_CACHE, title: null,-- description: 'Ask a picture, compare the local reader with an AI model, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.',++ description: 'Ask a picture, read text, check a page, find your images and use local face privacy tools with consent for personal matching.',jsonLd: homeJsonLd(config),body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),}));++ r.get('/people', async (req, res) => page(req, res, { title: 'People and consent', body: raw(peopleViews.people({++ signedIn: Boolean(signedIn(req)), consents: signedIn(req) ? await faces.listConsents(req.viewer.subject) : [],++ texts: faces.TEXT, regions: config.faces.allowedRegions,++ })) }));++ r.get('/people/privacy', (req, res) => page(req, res, { title: 'Face privacy', body: raw(peopleViews.privacy()) }));++ r.post('/people/consent/:feature', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'People', text: 'Sign in to choose.', next: '/people' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ try {++ if (req.body.enabled === 'yes') await faces.consent(req.viewer.subject, req.params.feature, {++ signedName: req.body.signed_name, textVersion: req.body.text_version, channel: req.body.channel,++ adult: req.body.adult === 'true', region: req.body.region,++ });++ else await faces.revoke(req.viewer.subject, req.params.feature, req.body.channel);++ return res.redirect(303, '/people');++ } catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || 'That choice could not be saved.')) }, err.status || 400); }++ });++ r.post('/people/enrol/:feature', uploadMiddleware, async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'People', text: 'Sign in to enrol.', next: '/people' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ try { await faces.enrol(req.viewer.subject, req.params.feature, (await imageOf(req)).buffer, req.body.channel); return res.redirect(303, '/people'); }++ catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || err.message || 'Enrolment failed.')) }, err.status || 400); }++ });++const toolPage = (req, res, title, intro, fields) => page(req, res, {title, cache: PUBLIC_CACHE,body: html`<main class="vi-wrap vi-page-pad vi-narrow"><header class="vi-section-head"><h1>${title}</h1><p class="vi-lede">${intro}</p></header><div class="vi-panel"><p class="vi-fine">These tools are available through Vision's JSON API. Sign in and send an image as multipart <code>image</code> or a data URL.</p>${fields}</div></main>`,@@@ -7,9 +7,10 @@* a person: their Network user token as a Bearer, or this site's session cookie (vision_at).* { kind: 'service', sub: 'svc:design', cap: [...], project, claims }* a Network service, app or agent token for audience openvibe.vision. Each machine route names exactly one-- * capability (including the six network tools) and checks the-- * token's grant list with openvibe-contracts capabilities.grants. Unknown capability ids are accepted here-- * until the main session registers the manifests; the main session adds the grants.++ * capability (vision.ui.check for Design's Arena, vision.face.cast.match for Live's channel roster, vision.ask,++ * vision.ocr, vision.compare, and the six network tools) and checks the token's grant list with++ * openvibe-contracts capabilities.grants. Unknown capability ids are accepted here until the main session++ * registers the manifests; the main session adds the grants.* { kind: 'anonymous' }** A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the@@@ -22,7 +23,10 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;/** Vision's machine capabilities (the main session registers them; Vision accepts the grant directly). */--const CAPABILITIES = Object.freeze(['vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check', 'vision.alt', 'vision.find', 'vision.receipt', 'vision.product', 'vision.watch', 'vision.moments']);++const CAPABILITIES = Object.freeze([++ 'vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check', 'vision.face.cast.match',++ 'vision.alt', 'vision.find', 'vision.receipt', 'vision.product', 'vision.watch', 'vision.moments',++]);function decodePayload(token) {const parts = String(token || '').split('.');@@@ -2,15 +2,20 @@/*** Account export and deletion → Vision (ADR-033; openvibe-sdk/account-data). The rows a person owns are their-- * answers, their compare samples and their UI checks. The local OCR text is part of the answer; image bytes are-- * never stored, so there is nothing else to erase. A row written by a service token has no subject and is not a-- * person's account data.++ * answers, compare samples, UI checks, face consents, encrypted templates/uploads, cast tags and library groups.++ * A general answer keeps no image bytes; the opt-in face rows export their ciphertext, never the server key. A row++ * written by a service token has no subject and is not a person's account data.** The database stores bare usr_ ids in the subject columns; an exported file never includes another person's row.*/const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');const TABLES = [++ { table: 'face_tags', subject: 'subject', value: (usr) => usr, file: 'face-tags.json', erase: 'delete' },++ { table: 'face_clusters', subject: 'subject', value: (usr) => usr, file: 'face-clusters.json', erase: 'delete' },++ { table: 'face_media', subject: 'subject', value: (usr) => usr, file: 'face-media.json', erase: 'delete' },++ { table: 'face_templates', subject: 'subject', value: (usr) => usr, file: 'face-templates.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' },// 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' },@@@ -23,7 +28,7 @@ function create({ db, log = console } = {}) {db,service: 'vision',tables: TABLES,-- note: 'Image bytes are never stored: an answer holds its cleaned text, a sha256 of the image and model provenance.',++ note: 'General answers keep no image bytes: an answer holds its cleaned text, a sha256 of the image and model provenance. Face templates and opt-in uploads are exported as AES-GCM ciphertext, never the server key.',log,});}@@@ -20,6 +20,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }() => { for (const t of timers) clearInterval(t); },() => ctx.keys.client.stop(),async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },++ () => clearInterval(ctx.faceRetentionTick),() => ctx.s.close(),...extra,],@@@ -40,6 +41,12 @@ async function start() {ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);ctx.telemetryTick.unref();++ // At boot and daily, remove expired face data. Drills never run this job.++ if (!config.drill) {++ ctx.faceRetentionTick = setInterval(() => { ctx.faces.retention().catch((err) => console.error('[OpenVibe.Vision] face retention:', err.message)); }, 24 * 60 * 60_000);++ ctx.faceRetentionTick.unref();++ ctx.faces.retention().catch((err) => console.error('[OpenVibe.Vision] face retention:', err.message));++ }ctx.keys.client.start();// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the@@@ -8,7 +8,7 @@const { jwksStatus } = require('openvibe-sdk/auth');const { createReadiness } = require('openvibe-shared/ready');--function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null, models = null }) {++function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null, faces = null, models = null }) {return createReadiness({service: 'vision',release,@@@ -43,6 +43,12 @@ function createServiceReadiness({ s, config, valkey = null, release = null, ai =return v ? { ok: true, detail: v } : 'tesseract is not installed: OCR and the screenshot pass answer 503';},},++ { name: 'face_models', required: false, check: () => {++ const state = faces && faces.available();++ return state && state.yunet && state.sface && state.key++ ? { ok: true, detail: { mode: 'local ONNX only', detect: Boolean(state.yunet), match: Boolean(state.sface && state.key) } }++ : 'Face matching is unavailable: install YuNet/SFace weights and set VISION_FACE_KEY; detect/blur need only YuNet and other Vision routes still serve';++ } },],});}nnew file mode 100644@@@ -0,0 +1,9 @@++'use strict';++const path = require('path');++++// One host-owned data root; model weights are never part of the repository.++function dataDir(env = process.env) {++ return path.resolve(env.DATA_DIR || path.join(__dirname, '..', 'data'));++}++function modelsDir(env = process.env) { return path.join(dataDir(env), 'models'); }++module.exports = { dataDir, modelsDir };@@@ -27,6 +27,8 @@ const NAV = [{ label: 'Watch', href: '/watch' },{ label: 'Compare', href: '/compare' },{ label: 'UI check', href: '/ui-check' },++ { label: 'People', href: '/people' },++ { label: 'Privacy', href: '/people/privacy' },{ label: 'Developers', href: '/developers' },];@@@ -30,7 +30,7 @@ ${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your accroom: 'vision',hero: {title: 'See with the best model for the job',-- lede: 'Ask a picture. Read its text with a local OCR engine first. Compare the local reader with a routed vision model on the same image, with the cost and the time beside each answer. Check a page for the overflow, low contrast and layering faults a person would catch. No face recognition, ever.',++ lede: 'Ask a picture. Read its text with a local OCR engine first. Compare the local reader with a routed vision model on the same image, with the cost and the time beside each answer. Check a page for overflow and layering faults. Local face tools use separate consent for each identifying purpose.',start: { href: '/ask', label: 'Ask a picture' },chips: [{ label: 'Read text', href: '/ocr' }, { label: 'Compare models', href: '/compare' }, { label: 'UI check', href: '/ui-check' }, { label: 'The API', href: '/developers' }],proof: [{ value: '0.0000', label: 'USD for the local OCR first pass' }],@@@ -57,9 +57,9 @@ ${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your acc},],faq: { items: [-- ['Does Vision recognise faces?', 'No. Never. Vision identifies what an image shows without identifying people, and public AI text never describes a person\'s looks. The prompt and the stored answer both enforce the no-looks rule.'],++ ['Does Vision recognise faces?', 'Only for a person who separately opts into a specific purpose: self verification, self search, cast tagging or private library grouping. Detect and blur never identify anyone. Public AI text never describes a person\'s looks.'],['Which model answers?', 'Every answer names the model and provider that actually answered and the reason it was chosen. Local OCR is first for a read question; otherwise OpenVibe.AI routes the run and its provenance is what Vision reports.'],-- ['Are my images stored?', 'No image bytes are stored. An answer keeps a sha256, the media type, the cleaned text and the model provenance. A signed-in person\'s rows are exported and erased with their account.'],++ ['Are my images stored?', 'General answers keep no image bytes. An opt-in face library stores encrypted uploads and vectors privately; turning its purposes off erases derived data. Account export and deletion include those rows.'],] },});const at = room.indexOf('<section class="sc-room-scenes"');@@@ -85,7 +85,7 @@ function ask({ signedIn = false, ai = false, problem = null, values = {} } = {})const v = { task: 'ask', mode: 'auto', ...values };return `<div class="vi-wrap" id="vi-ask"><header class="vi-page-h"><p class="vi-kicker">Ask a picture</p><h1>What does the image show?</h1>--<p class="vi-lede">Describe, read, translate, identify or count. A read question goes to the local OCR reader first; the answer says which engine answered and why. No face recognition, ever.</p></header>++<p class="vi-lede">Describe, read, translate, identify or count. A read question goes to the local OCR reader first; the answer says which engine answered and why. Face matching has separate consent controls on <a href="/people">People</a>.</p></header>${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}${signedIn ? '' : `<p class="vi-note">Ask saves the answer to an account. <a href="/auth/login?next=%2Fask">Sign in with OpenVibe</a> to ask; <a href="/ocr">OCR</a> and the <a href="/ui-check">UI check</a> work without one.</p>`}${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured on this host. A read question still runs on the local OCR engine.</p>'}@@@ -241,8 +241,23 @@ ${ep('GET', '/api/v1/ui-checks/:id', 'One saved UI check, for its owner or a ser${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}${ep('GET', '/api/v1/benchmarks?task=&limit=', 'Aggregate compare samples: task, engine, provider, model, samples, mean latency and mean cost. No subject data.')}</dl></section>++<section id="vi-faces"><h2>Faces, by consent</h2><dl class="vi-eps">++${ep('POST', '/api/v1/faces/detect', 'Anyone. YuNet boxes and a count; no template, identity or cross-frame tracking.')}++${ep('POST', '/api/v1/faces/blur', 'Anyone. The same YuNet boxes drive an image/png redaction. Both open routes are rate-limited.')}++${ep('GET', '/api/v1/faces/consents/text', 'The four signed texts and their versions. A person signs one purpose with <code>signed_name</code> and <code>text_version</code> at <code>POST /api/v1/faces/consents/:feature</code>; <code>DELETE</code> revokes and erases.')}++${ep('POST', '/api/v1/faces/enrol/:feature', 'A person, after that purpose\'s consent: exactly one face into an AES-GCM encrypted SFace template.')}++${ep('POST', '/api/v1/faces/verify', 'A person. Returns <code>{ score, match, threshold, liveness: "not_checked" }</code> against their own template, never an identity. A match alone unlocks nothing.')}++${ep('POST', '/api/v1/faces/media', 'A person with find or library consent. Stores an encrypted Vision upload; <code>GET /api/v1/faces/media/:id</code> is owner-only.')}++${ep('POST', '/api/v1/faces/find', 'A person with self-search consent. Matches only their own template in their own Vision uploads and returns private links.')}++${ep('POST', '/api/v1/faces/cast/match', 'Live with <code>vision.face.cast.match</code> and a signed <code>channel_id</code> claim. Reads only that channel\'s adult, consenting roster.')}++${ep('GET', '/api/v1/faces/cast/tags', 'A person\'s own cast tags; revoking a grant deletes them.')}++${ep('POST', '/api/v1/faces/library/cluster', 'A person with library consent in an allowed region. Private clusters; unknown faces stay unlabelled.')}++${ep('GET', '/api/v1/faces/library/groups', 'A person\'s own groups. No subject or public index.')}++${ep('POST', '/api/v1/faces/library/correct', 'A person\'s own group correction: <code>exclude</code>, <code>merge</code> or <code>split</code>.')}++</dl>++<p class="vi-fine"><a href="/people">Manage choices</a> · <a href="/people/privacy">Retention and privacy</a></p></section><section id="vi-capabilities"><h2>Capabilities and grants</h2>--<p class="vi-fine">Vision needs no new AI capability: outgoing calls are <code>ai.run.create</code> to OpenVibe.AI, audience <code>openvibe.ai</code>, namespace <code>ai.*</code>. The capabilities Vision offers are <code>vision.ask</code>, <code>vision.ocr</code>, <code>vision.compare</code> and <code>vision.ui.check</code>; the main session registers the manifests and grants them to Design (the Arena) and any other caller.</p>++<p class="vi-fine">Vision needs no new AI capability: outgoing calls are <code>ai.run.create</code> to OpenVibe.AI, audience <code>openvibe.ai</code>, namespace <code>ai.*</code>. The capabilities Vision offers are <code>vision.ask</code>, <code>vision.ocr</code>, <code>vision.compare</code>, <code>vision.ui.check</code> and <code>vision.face.cast.match</code>; the main session registers the manifests and grants them to Design (the Arena), Live (the cast roster) and any other caller.</p><pre class="vi-code">POST /api/v1/ui-checkAuthorization: Bearer <Network service token for openvibe.vision>Content-Type: application/json@@@ -250,7 +265,7 @@ Content-Type: application/json{ "html": "<!doctype html>…", "viewport": { "w": 390, "h": 844 } }</pre></section><section id="vi-limits"><h2>What leaves this host</h2>--<p class="vi-fine">The local OCR engine and the UI check never leave the host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; AI's own routing, quotas and provenance apply. Vision holds no provider key. No image bytes are stored, and no face recognition is ever used. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p>++<p class="vi-fine">The local OCR engine, UI check and all face tools run on this host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; face images and templates never go there. General answers store no image bytes; opt-in face uploads are encrypted and private. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p><p class="vi-fine">AI on this host: ${ai ? 'configured' : 'not configured (local engines only)'}. Model hints: ${esc((models || []).join(', ') || 'from AI\'s routes at request time')}.</p></section><section id="vi-search"><h2>Search</h2><p class="vi-fine">v1 has no public objects: answers and UI checks belong to the caller and are not indexed, so Vision writes no Search document and has no outbox. The v2 Arena's public leaderboards are where public records will begin.</p></section>nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++const { esc } = require('../render/html');++++const LABELS = { verify: 'Verify yourself', find: 'Find yourself', cast: 'Cast tagging', library: "Your library's people" };++function people({ signedIn, consents = [], texts, regions = [] }) {++ const active = (f) => consents.find((c) => c.feature === f && !c.revoked_at);++ return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-people"><header class="vi-page-h"><p class="vi-kicker">Your choice</p><h1>People in Vision</h1><p class="vi-lede">Each purpose has its own consent. Face matching runs locally. Turning a feature off erases its template at once.</p></header>++${signedIn ? '' : '<p class="vi-note">Sign in with your Network account to manage face features.</p>'}++${Object.entries(texts).map(([feature, item]) => {++ const on = active(feature), scope = on?.scope || '', needed = on ? '' : ' required';++ return `<section class="vi-card vi-person-feature" id="vi-${feature}"><h2>${esc(LABELS[feature])}</h2><p>${esc(item.text)}</p>++<form class="vi-form" method="post" action="/people/consent/${feature}">++<input type="hidden" name="text_version" value="${esc(item.version)}">++ ${feature === 'cast' ? `<label class="vi-field"><span>Named channel ID</span><input name="channel"${needed} value="${esc(scope)}" placeholder="live:your-channel"></label><label class="vi-check"><input type="checkbox" name="adult" value="true"${needed}> I confirm I am an adult co-streamer</label>` : ''}++ ${feature === 'library' ? `<label class="vi-field"><span>Region</span><input name="region"${needed} placeholder="US-IL" value="${esc(regions[0] || '')}"></label><p class="vi-fine">Available regions: ${esc(regions.join(', ') || 'none configured')}</p>` : ''}++ <label class="vi-field"><span>Signature (your typed name)</span><input name="signed_name" autocomplete="name"${needed} maxlength="160"></label>++<label class="vi-check"><input type="checkbox" role="switch" name="enabled" value="yes" ${on ? 'checked' : ''}${signedIn ? '' : ' disabled'}> ${on ? 'On' : 'Off'}</label>++<button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Save this choice</button></form>++${on && feature !== 'library' ? `<form method="post" action="/people/enrol/${feature}" enctype="multipart/form-data" class="vi-form"><input type="hidden" name="channel" value="${esc(scope)}"><label class="vi-field"><span>One face image for enrolment</span><input type="file" name="image" accept="image/*" required></label><button class="vi-btn vi-btn-quiet" type="submit">Enrol my face</button></form>` : ''}++${on ? `<p class="vi-fine">Granted ${esc(String(on.granted_at))}${scope ? ` for ${esc(scope)}` : ''} · consent ${esc(on.text_version)}</p>` : ''}</section>`;++}).join('')}++<p class="vi-fine">A face match cannot unlock an account by itself. Liveness is not checked yet; use another factor and a non-biometric route. <a href="/people/privacy">Read the retention and privacy rules.</a></p></div>`;++}++function privacy() {++ return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-privacy"><header class="vi-page-h"><p class="vi-kicker">Privacy</p><h1>Faces stay in your chosen purpose</h1></header>++<section class="vi-card"><h2>What happens</h2><p>Detect, count and blur use local YuNet without identity templates or tracking. Verification compares only your enrolled template with your own attempt. Find yourself searches only your own Vision uploads. Cast tagging checks a named channel roster of consenting adults. Library groups are private, region gated, and unknown faces remain unlabelled.</p></section>++<section class="vi-card"><h2>Retention and control</h2><p>Templates and derived tags are erased when you switch a purpose off. A daily job removes active templates at the latest three years after your last use. Revoked consent audit records are removed within one year after the purpose ends. Account export and deletion include face templates, consents, uploads, groups and tags.</p></section>++<section class="vi-card"><h2>What never happens</h2><p>No face or template is sent to a cloud model, public Search, model prompts or Bench. No stranger identification against the open web. No public face index. AI text never describes a person's looks. A face match alone never unlocks anything: liveness is not checked and a second factor and non-biometric route are required for consequential use.</p></section>++<p><a href="/privacy">The full OpenVibe.Vision privacy policy</a> · <a href="/people">Manage your choices</a></p></div>`;++}++module.exports = { people, privacy };nnew file mode 100644@@@ -0,0 +1,138 @@++'use strict';++++// YuNet/SFace ONNX inference runs in this process. No face bytes or vectors reach OpenVibe.AI.++const fs = require('fs');++const path = require('path');++const sharp = require('sharp');++const { modelsDir } = require('../paths');++++const FILES = Object.freeze({++ yunet: 'face_detection_yunet_2023mar.onnx',++ sface: 'face_recognition_sface_2021dec.onnx',++});++const STRIDES = [8, 16, 32];++const YUNET_INPUT = 640;++const TARGET = [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]];++++function cosine(a, b) {++ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;++ let dot = 0; let aa = 0; let bb = 0;++ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; aa += a[i] * a[i]; bb += b[i] * b[i]; }++ return aa && bb ? dot / Math.sqrt(aa * bb) : 0;++}++function iou(a, b) {++ const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y);++ const w = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x);++ const h = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y);++ return w * h / (a.width * a.height + b.width * b.height - w * h || 1);++}++function decodeYuNet(outputs, width = YUNET_INPUT, height = YUNET_INPUT, threshold = 0.7) {++ const found = [];++ for (const stride of STRIDES) {++ const cls = outputs[`cls_${stride}`]?.data, obj = outputs[`obj_${stride}`]?.data;++ const box = outputs[`bbox_${stride}`]?.data, kps = outputs[`kps_${stride}`]?.data;++ if (!cls || !obj || !box || !kps) throw new Error('YuNet output shape is unsupported');++ const cols = width / stride, rows = height / stride;++ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {++ const i = r * cols + c;++ const score = Math.sqrt(Math.max(0, Math.min(1, cls[i])) * Math.max(0, Math.min(1, obj[i])));++ if (score < threshold) continue;++ const cx = (c + box[i * 4]) * stride, cy = (r + box[i * 4 + 1]) * stride;++ const w = Math.exp(box[i * 4 + 2]) * stride, h = Math.exp(box[i * 4 + 3]) * stride;++ if (!Number.isFinite(w) || !Number.isFinite(h)) continue;++ found.push({ x: cx - w / 2, y: cy - h / 2, width: w, height: h, score,++ landmarks: Array.from({ length: 5 }, (_, j) => [(kps[i * 10 + 2 * j] + c) * stride, (kps[i * 10 + 2 * j + 1] + r) * stride]) });++ }++ }++ found.sort((a, b) => b.score - a.score);++ const keep = [];++ for (const f of found.slice(0, 5000)) if (keep.every((k) => iou(f, k) < 0.3)) keep.push(f);++ return keep;++}++function chw(rgb, width, height, bgr = false) {++ const plane = width * height, out = new Float32Array(plane * 3);++ for (let i = 0; i < plane; i++) {++ out[i] = rgb[i * 3 + (bgr ? 2 : 0)];++ out[plane + i] = rgb[i * 3 + 1];++ out[plane * 2 + i] = rgb[i * 3 + (bgr ? 0 : 2)];++ }++ return out;++}++// Least-squares similarity transform, source landmarks → SFace's 112×112 landmark frame.++function alignment(points) {++ const mean = (p, axis) => p.reduce((n, x) => n + x[axis], 0) / p.length;++ const sx = mean(points, 0), sy = mean(points, 1), tx = mean(TARGET, 0), ty = mean(TARGET, 1);++ let a = 0, b = 0, den = 0;++ for (let i = 0; i < 5; i++) {++ const x = points[i][0] - sx, y = points[i][1] - sy;++ const u = TARGET[i][0] - tx, v = TARGET[i][1] - ty;++ a += x * u + y * v; b += x * v - y * u; den += x * x + y * y;++ }++ if (den < 1) throw new Error('face landmarks are degenerate');++ return { a: a / den, b: b / den, sx, sy, tx, ty };++}++function alignedRgb(raw, width, height, points) {++ const { a, b, sx, sy, tx, ty } = alignment(points), den = a * a + b * b;++ const out = Buffer.alloc(112 * 112 * 3);++ for (let y = 0; y < 112; y++) for (let x = 0; x < 112; x++) {++ const u = x - tx, v = y - ty;++ const px = (a * u + b * v) / den + sx, py = (-b * u + a * v) / den + sy;++ const x0 = Math.floor(px), y0 = Math.floor(py), fx = px - x0, fy = py - y0;++ for (let c = 0; c < 3; c++) {++ const at = (xx, yy) => xx >= 0 && yy >= 0 && xx < width && yy < height ? raw[(yy * width + xx) * 3 + c] : 0;++ out[(y * 112 + x) * 3 + c] = Math.round(at(x0, y0) * (1 - fx) * (1 - fy) + at(x0 + 1, y0) * fx * (1 - fy) + at(x0, y0 + 1) * (1 - fx) * fy + at(x0 + 1, y0 + 1) * fx * fy);++ }++ }++ return out;++}++function createFacesModel({ dir = modelsDir(), ort = null } = {}) {++ let detectionSession, matchingSession;++ const file = (kind) => path.join(dir, FILES[kind]);++ const available = () => ({ yunet: fs.existsSync(file('yunet')), sface: fs.existsSync(file('sface')) });++ async function session(kind) {++ const runtime = ort || require('onnxruntime-node');++ if (!fs.existsSync(file(kind))) { const e = new Error(`${kind} weights are not installed`); e.code = 'faces.model_unavailable'; throw e; }++ if (kind === 'yunet') return detectionSession ||= await runtime.InferenceSession.create(file(kind), { executionProviders: ['cpu'] });++ return matchingSession ||= await runtime.InferenceSession.create(file(kind), { executionProviders: ['cpu'] });++ }++ async function pixels(buffer) {++ return sharp(buffer, { limitInputPixels: 40_000_000, animated: false }).rotate().removeAlpha().toColourspace('srgb').raw().toBuffer({ resolveWithObject: true });++ }++ async function detect(buffer) {++ const runtime = ort || require('onnxruntime-node');++ const image = await pixels(buffer);++ const { data } = await sharp(image.data, { raw: image.info }).resize(YUNET_INPUT, YUNET_INPUT, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });++ const model = await session('yunet');++ const output = await model.run({ [model.inputNames[0]]: new runtime.Tensor('float32', chw(data, YUNET_INPUT, YUNET_INPUT, true), [1, 3, YUNET_INPUT, YUNET_INPUT]) });++ return decodeYuNet(output).map((f) => ({ ...f,++ x: f.x * image.info.width / YUNET_INPUT, y: f.y * image.info.height / YUNET_INPUT,++ width: f.width * image.info.width / YUNET_INPUT, height: f.height * image.info.height / YUNET_INPUT,++ landmarks: f.landmarks.map(([x, y]) => [x * image.info.width / YUNET_INPUT, y * image.info.height / YUNET_INPUT]),++ }));++ }++ async function embed(buffer, face) {++ const runtime = ort || require('onnxruntime-node');++ const image = await pixels(buffer);++ const aligned = alignedRgb(image.data, image.info.width, image.info.height, face.landmarks);++ const model = await session('sface');++ const out = await model.run({ [model.inputNames[0]]: new runtime.Tensor('float32', chw(aligned, 112, 112), [1, 3, 112, 112]) });++ const vector = Array.from(out[model.outputNames[0]].data);++ if (!vector.length || vector.some((v) => !Number.isFinite(v))) throw new Error('SFace returned an invalid vector');++ return vector;++ }++ async function blur(buffer, faces) {++ const image = await pixels(buffer);++ const base = sharp(image.data, { raw: image.info });++ const layers = [];++ for (const f of faces) {++ const x = Math.max(0, Math.floor(f.x - f.width * 0.12)), y = Math.max(0, Math.floor(f.y - f.height * 0.12));++ const w = Math.min(image.info.width - x, Math.ceil(f.width * 1.24)), h = Math.min(image.info.height - y, Math.ceil(f.height * 1.24));++ if (w < 1 || h < 1) continue;++ const crop = await sharp(image.data, { raw: image.info }).extract({ left: x, top: y, width: w, height: h }).blur(Math.max(8, Math.round(Math.min(w, h) / 8))).png().toBuffer();++ layers.push({ input: crop, left: x, top: y });++ }++ return base.composite(layers).png().toBuffer();++ }++ return { available, detect, embed, blur, cosine };++}++module.exports = { createFacesModel, decodeYuNet, cosine, FILES, YUNET_INPUT };nnew file mode 100644@@@ -0,0 +1,225 @@++'use strict';++const crypto = require('crypto');++const { VisionError } = require('./service');++++const FEATURES = Object.freeze(['verify', 'find', 'cast', 'library']);++const TEXT = Object.freeze({++ verify: { version: 'face-verify-2026-10-28', text: 'Use my face template only to compare my own verification attempt with my Network account. A match alone never unlocks anything.' },++ find: { version: 'face-find-2026-10-28', text: 'Search only my own Vision uploads for my face. Results are private to me; no public face index is made.' },++ cast: { version: 'face-cast-2026-10-28', text: 'Match my face only within the named channel roster. I am an adult. Revoking this grant removes my tags.' },++ library: { version: 'face-library-2026-10-28', text: 'Cluster faces in my own Vision library. Other people in my uploads may be processed. Unknown faces stay unlabelled; I can exclude, merge, split or erase the groups.' },++});++const deny = (code, detail, status = 403) => { throw new VisionError(status, `faces.${code}`, detail); };++const scopeOf = (feature, channel) => {++ if (feature !== 'cast') return '';++ if (!/^[a-zA-Z0-9][a-zA-Z0-9:_-]{1,119}$/.test(String(channel || ''))) deny('invalid_channel', 'Provide a named channel ID.', 400);++ return channel;++};++function keyOf(raw) {++ if (!raw) return null;++ const value = String(raw).trim();++ if (/^[0-9a-fA-F]{64}$/.test(value)) return Buffer.from(value, 'hex');++ const decoded = Buffer.from(value, 'base64');++ if (decoded.length === 32) return decoded;++ // A host may inject exactly 32 raw bytes instead of base64; both are an AES-256 key.++ const utf8 = Buffer.from(value, 'utf8');++ return utf8.length === 32 ? utf8 : null;++}++function seal(key, value) {++ const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);++ const data = Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value));++ const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);++ return Buffer.concat([nonce, cipher.getAuthTag(), encrypted]).toString('base64');++}++function open(key, value, asBuffer = false) {++ const bytes = Buffer.from(value, 'base64');++ const decipher = crypto.createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12));++ decipher.setAuthTag(bytes.subarray(12, 28));++ const data = Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]);++ return asBuffer ? data : JSON.parse(data.toString('utf8'));++}++function createFaces({ s, config, model }) {++ const db = s.db;++ const key = keyOf(config.faces.key);++ const timestamp = () => s.iso();++ function requireKey() { if (!key) deny('key_unavailable', 'Face encryption is not configured.', 503); }++ function requireModel(kind) {++ if (!model.available()[kind]) deny('model_unavailable', `${kind} weights are not installed.`, 503);++ }++ function regionAllowed(region) { return Boolean(region && config.faces.allowedRegions.includes(String(region).trim().toUpperCase())); }++ async function active(subject, feature, scope = '') {++ return db.maybe('SELECT * FROM face_consents WHERE subject=$1 AND feature=$2 AND scope=$3 AND revoked_at IS NULL', [subject, feature, scope]);++ }++ async function requireConsent(subject, feature, scope = '') {++ const row = await active(subject, feature, scope);++ if (!row) deny('consent_required', `Enable ${feature} consent first.`);++ return row;++ }++ async function consent(subject, feature, { signedName, textVersion, channel, adult, region } = {}) {++ if (!FEATURES.includes(feature)) deny('invalid_feature', 'Unknown face feature.', 400);++ const scope = scopeOf(feature, channel), at = timestamp();++ const existing = await active(subject, feature, scope);++ if (existing) return { feature, scope, text_version: existing.text_version, granted_at: existing.granted_at };++ if (textVersion !== TEXT[feature].version || !String(signedName || '').trim() || String(signedName).length > 160) deny('signature_required', 'Sign the current feature consent text.', 400);++ if (feature === 'cast' && adult !== true) deny('adult_required', 'Cast enrolment is for adults only.', 400);++ if (feature === 'library' && !regionAllowed(region)) deny('region_disabled', 'Library grouping is not enabled in this region.');++ await db.exec('INSERT INTO face_consents (id, subject, feature, scope, text_version, signature, granted_at, last_used_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)',++ [s.newId('fco'), subject, feature, scope, textVersion, String(signedName).trim(), at]);++ return { feature, scope, text_version: textVersion, granted_at: at };++ }++ async function listConsents(subject) {++ const rows = await db.many('SELECT id, feature, scope, text_version, granted_at, revoked_at FROM face_consents WHERE subject=$1 ORDER BY granted_at DESC', [subject]);++ return rows;++ }++ async function revoke(subject, feature, channel) {++ if (!FEATURES.includes(feature)) deny('invalid_feature', 'Unknown face feature.', 400);++ const scope = scopeOf(feature, channel), at = timestamp();++ await s.tx(async (t) => {++ await t.exec('UPDATE face_consents SET revoked_at=$4, purpose_ends_at=$4 WHERE subject=$1 AND feature=$2 AND scope=$3 AND revoked_at IS NULL', [subject, feature, scope, at]);++ await t.exec('DELETE FROM face_templates WHERE subject=$1 AND feature=$2 AND scope=$3', [subject, feature, scope]);++ if (feature === 'cast') await t.exec('DELETE FROM face_tags WHERE subject=$1 AND channel=$2', [subject, scope]);++ if (feature === 'find' && !(await active(subject, 'library'))) await t.exec('DELETE FROM face_media WHERE subject=$1', [subject]);++ if (feature === 'library') {++ await t.exec('DELETE FROM face_clusters WHERE subject=$1', [subject]);++ if (!(await active(subject, 'find'))) await t.exec('DELETE FROM face_media WHERE subject=$1', [subject]);++ }++ });++ return { feature, scope, revoked_at: at, erased: true };++ }++ async function enrol(subject, feature, buffer, channel) {++ requireKey(); requireModel('yunet'); requireModel('sface');++ if (!['verify', 'find', 'cast'].includes(feature)) deny('invalid_feature', 'Enrolment needs verify, find or cast consent.', 400);++ const scope = scopeOf(feature, channel);++ await requireConsent(subject, feature, scope);++ const faces = await model.detect(buffer);++ if (faces.length !== 1) deny('one_face_required', 'Enrolment needs exactly one face.', 422);++ const vector = await model.embed(buffer, faces[0]), at = timestamp();++ await db.exec(`INSERT INTO face_templates (subject, feature, scope, ciphertext, created_at, last_used_at) VALUES ($1,$2,$3,$4,$5,$5)++ ON CONFLICT (subject, feature, scope) DO UPDATE SET ciphertext=EXCLUDED.ciphertext, last_used_at=EXCLUDED.last_used_at`,++ [subject, feature, scope, seal(key, vector), at]);++ await touch(subject, feature, scope);++ return { enrolled: true, feature, scope, liveness: 'not_checked' };++ }++ async function template(subject, feature, scope = '') {++ requireKey(); await requireConsent(subject, feature, scope);++ const row = await db.maybe('SELECT ciphertext FROM face_templates WHERE subject=$1 AND feature=$2 AND scope=$3', [subject, feature, scope]);++ if (!row) deny('enrolment_required', `Enrol in ${feature} first.`);++ return open(key, row.ciphertext);++ }++ async function touch(subject, feature, scope = '') {++ const at = timestamp();++ await db.exec('UPDATE face_consents SET last_used_at=$4 WHERE subject=$1 AND feature=$2 AND scope=$3 AND revoked_at IS NULL', [subject, feature, scope, at]);++ await db.exec('UPDATE face_templates SET last_used_at=$4 WHERE subject=$1 AND feature=$2 AND scope=$3', [subject, feature, scope, at]);++ }++ async function verify(subject, buffer) {++ requireModel('yunet'); requireModel('sface');++ const own = await template(subject, 'verify');++ const faces = await model.detect(buffer);++ if (faces.length !== 1) deny('one_face_required', 'Verification needs exactly one face.', 422);++ const score = model.cosine(own, await model.embed(buffer, faces[0]));++ await touch(subject, 'verify');++ return { score, match: score >= config.faces.threshold, threshold: config.faces.threshold, liveness: 'not_checked' };++ }++ async function detect(buffer) { requireModel('yunet'); const faces = await model.detect(buffer); return { count: faces.length, boxes: faces.map(({ x, y, width, height, score }) => ({ x, y, width, height, score })) }; }++ async function blur(buffer) { requireModel('yunet'); const faces = await model.detect(buffer); return { count: faces.length, buffer: await model.blur(buffer, faces) }; }++ async function upload(subject, buffer, mime) {++ requireKey(); requireModel('yunet'); requireModel('sface');++ const findConsent = await active(subject, 'find'), libraryConsent = await active(subject, 'library');++ if (!findConsent && !libraryConsent) deny('consent_required', 'Enable find or library consent first.');++ const faces = await model.detect(buffer), vectors = [];++ for (const face of faces) vectors.push(await model.embed(buffer, face));++ const id = s.newId('fme'), at = timestamp();++ await db.exec('INSERT INTO face_media (id,subject,image_ciphertext,image_mime,vectors_ciphertext,created_at,last_used_at) VALUES ($1,$2,$3,$4,$5,$6,$6)',++ [id, subject, seal(key, buffer), mime, seal(key, vectors), at]);++ if (findConsent) await touch(subject, 'find');++ if (libraryConsent) await touch(subject, 'library');++ return { id, private_link: `/api/v1/faces/media/${id}`, face_count: faces.length };++ }++ async function media(subject, id) {++ requireKey();++ // Check ownership before consent so another person cannot probe which media ids exist.++ const row = await db.maybe('SELECT image_ciphertext,image_mime FROM face_media WHERE id=$1 AND subject=$2', [id, subject]);++ if (!row) deny('not_found', 'No such media.', 404);++ if (!(await active(subject, 'find')) && !(await active(subject, 'library'))) deny('consent_required', 'Enable find or library consent first.');++ return { buffer: open(key, row.image_ciphertext, true), mime: row.image_mime };++ }++ async function find(subject) {++ const own = await template(subject, 'find');++ const rows = await db.many('SELECT id,vectors_ciphertext FROM face_media WHERE subject=$1 ORDER BY created_at DESC LIMIT 500', [subject]);++ const results = [];++ for (const row of rows) {++ const scores = open(key, row.vectors_ciphertext).map((v) => model.cosine(own, v));++ const score = Math.max(0, ...scores);++ if (score >= config.faces.threshold) results.push({ private_link: `/api/v1/faces/media/${row.id}`, score });++ }++ await touch(subject, 'find');++ return { results };++ }++ // Live calls this with a channel, a frame and a source-owned media reference. Only that channel's opted-in roster is read.++ async function castMatch(channel, buffer, mediaRef) {++ requireKey(); requireModel('yunet'); requireModel('sface'); scopeOf('cast', channel);++ if (!/^[a-zA-Z0-9][a-zA-Z0-9:/_-]{1,159}$/.test(String(mediaRef || ''))) deny('invalid_media_ref', 'Provide a private media reference.', 400);++ const roster = await db.many(`SELECT c.subject,t.ciphertext FROM face_consents c JOIN face_templates t++ ON t.subject=c.subject AND t.feature='cast' AND t.scope=c.scope++ WHERE c.feature='cast' AND c.scope=$1 AND c.revoked_at IS NULL`, [channel]);++ const faces = await model.detect(buffer), matches = [];++ for (const face of faces) {++ const probe = await model.embed(buffer, face);++ const ranked = roster.map((r) => ({ subject: r.subject, score: model.cosine(open(key, r.ciphertext), probe) })).sort((a, b) => b.score - a.score);++ if (ranked[0] && ranked[0].score >= config.faces.threshold) {++ const row = ranked[0], at = timestamp();++ await db.exec('INSERT INTO face_tags (id,subject,channel,media_ref,created_at,last_used_at) VALUES ($1,$2,$3,$4,$5,$5)', [s.newId('ftg'), row.subject, channel, mediaRef, at]);++ await touch(row.subject, 'cast', channel);++ matches.push({ subject: row.subject, score: row.score, box: { x: face.x, y: face.y, width: face.width, height: face.height } });++ }++ }++ return { channel, matches, liveness: 'not_checked' };++ }++ async function tags(subject) { return db.many('SELECT id,channel,media_ref,created_at FROM face_tags WHERE subject=$1 ORDER BY created_at DESC', [subject]); }++ async function cluster(subject, region) {++ requireKey(); requireModel('yunet'); requireModel('sface');++ if (!regionAllowed(region)) deny('region_disabled', 'Library grouping is not enabled in this region.');++ await requireConsent(subject, 'library');++ const rows = await db.many('SELECT id,vectors_ciphertext FROM face_media WHERE subject=$1', [subject]);++ await db.exec('DELETE FROM face_clusters WHERE subject=$1', [subject]);++ const representatives = [];++ for (const row of rows) for (const vector of open(key, row.vectors_ciphertext)) {++ const at = timestamp();++ const match = representatives.find((g) => model.cosine(g.vector, vector) >= config.faces.threshold);++ const group = match ? match.id : s.newId('fgr');++ if (!match) representatives.push({ id: group, vector });++ await db.exec('INSERT INTO face_clusters (id,subject,media_id,vector_ciphertext,group_id,created_at,last_used_at) VALUES ($1,$2,$3,$4,$5,$6,$6)',++ [s.newId('fcl'), subject, row.id, seal(key, vector), group, at]);++ }++ await touch(subject, 'library');++ return groups(subject);++ }++ async function groups(subject) {++ await requireConsent(subject, 'library');++ return db.many('SELECT id,media_id,group_id,label,excluded FROM face_clusters WHERE subject=$1 ORDER BY created_at DESC', [subject]);++ }++ async function correction(subject, action, ids) {++ await requireConsent(subject, 'library');++ if (!['exclude', 'merge', 'split'].includes(action) || !Array.isArray(ids) || !ids.length || ids.length > 100) deny('invalid_action', 'Use exclude, merge or split with cluster IDs.', 400);++ const rows = await db.many('SELECT id FROM face_clusters WHERE subject=$1 AND id=ANY($2::text[])', [subject, ids]);++ if (rows.length !== ids.length) deny('not_found', 'A cluster is not in your library.', 404);++ if (action === 'exclude') await db.exec('UPDATE face_clusters SET excluded=true,label=NULL WHERE subject=$1 AND id=ANY($2::text[])', [subject, ids]);++ if (action === 'merge') await db.exec('UPDATE face_clusters SET group_id=$3 WHERE subject=$1 AND id=ANY($2::text[]) AND excluded=false', [subject, ids, s.newId('fgr')]);++ if (action === 'split') for (const id of ids) await db.exec('UPDATE face_clusters SET group_id=$3 WHERE subject=$1 AND id=$2', [subject, id, s.newId('fgr')]);++ return { action, updated: rows.length };++ }++ async function retention() {++ const at = timestamp();++ const expired = await db.many(`SELECT subject,feature,scope FROM face_consents WHERE revoked_at IS NULL++ AND last_used_at <= $1::timestamptz - interval '3 years'`, [at]);++ for (const row of expired) await revoke(row.subject, row.feature, row.scope);++ // CUBI: dispose of audit consents within one year after the purpose ended.++ const old = await db.many(`SELECT id FROM face_consents WHERE revoked_at IS NOT NULL++ AND purpose_ends_at <= $1::timestamptz - interval '1 year'`, [at]);++ await db.exec(`DELETE FROM face_consents WHERE revoked_at IS NOT NULL++ AND purpose_ends_at <= $1::timestamptz - interval '1 year'`, [at]);++ return { expired: expired.length, consent_records_deleted: old.length };++ }++ return { TEXT, available: () => ({ ...model.available(), key: Boolean(key) }), consent, listConsents, revoke, enrol, verify, detect, blur, upload, media, find, castMatch, tags, cluster, groups, correction, retention, regionAllowed };++}++module.exports = { createFaces, FEATURES, TEXT, seal, open, keyOf };nnew file mode 100644@@@ -0,0 +1,441 @@++'use strict';++/**++ * Faces with consent (§5.8b), with a fake ONNX model: no weights and no inference in the test.++ *++ * Proves the rules the plan makes non-negotiable: detect/blur need no identity, every matching purpose has its own++ * signed consent, templates and uploads are AES-GCM ciphertext at rest, a cast channel reads only its own roster,++ * library grouping is region-gated and unknown faces stay unlabelled, opt-out erases at once, the retention job++ * expires data, and ADR-033 export/erasure include every face row.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { cosine } = require('../server/vision/faces-model');++const { keyOf, open } = require('../server/vision/faces');++++process.env.VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN = '100000';++++const KEY = Buffer.alloc(32, 7).toString('base64');++const FACE = { x: 20, y: 20, width: 80, height: 80, score: 0.99, landmarks: [[40, 50], [80, 50], [60, 70], [45, 90], [75, 90]] };++++/** A stand-in for YuNet/SFace: faces and vectors are injected per call, so no image is ever really inferred. */++function fakeFacesModel() {++ const state = { faces: [{ ...FACE }], vector: [1, 0, 0, 0], vectors: null, at: 0, blurred: null, detectCalls: 0 };++ const next = () => {++ const v = state.vectors ? state.vectors[state.at++ % state.vectors.length] : state.vector;++ return Array.from(v);++ };++ return {++ state,++ available: () => ({ yunet: true, sface: true }),++ detect: async () => { state.detectCalls++; return state.faces.map((f) => ({ ...f, landmarks: f.landmarks.map((p) => p.slice()) })); },++ embed: async () => next(),++ blur: async (_buffer, faces) => { state.blurred = faces.length; return Buffer.from(`BLURRED:${faces.length}`, 'utf8'); },++ cosine,++ };++}++++function liveToken(t, channel) {++ const now = Math.floor(Date.now() / 1000);++ return t.network.signJwt({++ iss: t.network.issuer(), sub: 'svc:live', actor_type: 'service', aud: ['openvibe.vision'],++ cap: ['vision.face.cast.match'], channel_id: channel, iat: now, exp: now + 300, jti: crypto.randomUUID(),++ });++}++++(async () => {++ const model = fakeFacesModel();++ const t = await boot({++ env: { VISION_FACE_KEY: KEY, VISION_FACE_LIBRARY_REGIONS: 'US-IL, US-TX' },++ facesModel: model,++ });++ const ana = t.network.addUser('ana');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++ const dee = t.network.addUser('dee');++ const eve = t.network.addUser('eve');++ const png = await t.textImage('A FACE');++ const db = t.ctx.s.db;++++ const oneFace = () => { model.state.faces = [{ ...FACE }]; };++ const vector = (v) => { model.state.vector = v; model.state.vectors = null; model.state.at = 0; };++ const consent = (as, feature, extra = {}) => t.get(`/api/v1/faces/consents/${feature}`, {++ as, json: { signed_name: 'Test Person', text_version: t.ctx.faces.TEXT[feature].version, ...extra },++ });++ const enrol = (as, feature, fields = {}) => t.get(`/api/v1/faces/enrol/${feature}`, { as, multipart: { fields, image: png } });++ const upload = (as) => t.get('/api/v1/faces/media', { as, multipart: { image: png } });++ const verify = (as) => t.get('/api/v1/faces/verify', { as, multipart: { image: png } });++ const count = async (table, subject) => Number(await db.value(`SELECT COUNT(*)::int AS n FROM ${table} WHERE subject=$1`, [subject]));++++ try {++ await check('detect and blur are open privacy tools; they store no template and return no identity', async () => {++ oneFace();++ const detect = await t.get('/api/v1/faces/detect', { multipart: { image: png } });++ assert.strictEqual(detect.status, 200, detect.text);++ assert.strictEqual(detect.json().count, 1);++ assert.strictEqual(detect.json().boxes.length, 1);++ assert.deepStrictEqual(Object.keys(detect.json()).sort(), ['boxes', 'count']);++ assert.strictEqual(await count('face_templates', ana.subject), 0);++++ const blur = await t.get('/api/v1/faces/blur', { multipart: { image: png } });++ assert.strictEqual(blur.status, 200, blur.text);++ assert.match(blur.headers.get('content-type'), /^image\/png/);++ assert.strictEqual(blur.headers.get('x-faces-blurred'), '1');++ assert.strictEqual(blur.buffer.toString('utf8'), 'BLURRED:1');++ assert.strictEqual(model.state.blurred, 1);++ });++++ await check('every matching purpose refuses an anonymous caller or a person without its consent', async () => {++ vector([1, 0, 0, 0]);++ assert.strictEqual((await verify(ana)).status, 403);++ assert.strictEqual((await verify(ana)).json().code, 'faces.consent_required');++ assert.strictEqual((await t.get('/api/v1/faces/find', { as: ana, json: {} })).status, 403);++ assert.strictEqual((await enrol(ana, 'verify')).status, 403);++ assert.strictEqual((await enrol(ana, 'cast', { channel: 'live:one' })).status, 403);++ assert.strictEqual((await upload(ana)).status, 403);++ assert.strictEqual((await t.get('/api/v1/faces/library/cluster', { as: ana, json: { region: 'US-IL' } })).status, 403);++ assert.strictEqual((await t.get('/api/v1/faces/library/groups', { as: ana })).status, 403);++ assert.strictEqual((await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'exclude', ids: ['fcl_x'] } })).status, 403);++ const noTags = await t.get('/api/v1/faces/cast/tags', { as: ana });++ assert.strictEqual(noTags.status, 200);++ assert.deepStrictEqual(noTags.json(), []);++++ const anon = await t.get('/api/v1/faces/cast/match', { multipart: { fields: { channel: 'live:one', media_ref: 'media:a' }, image: png } });++ assert.strictEqual(anon.status, 403);++ assert.strictEqual(anon.json().code, 'faces.live_grant_required');++ assert.strictEqual((await t.get('/api/v1/faces/consents')).status, 401);++ const service = t.network.serviceToken({ sub: 'svc:live', cap: ['vision.face.cast.match'] });++ assert.strictEqual((await t.get('/api/v1/faces/verify', { bearer: service, multipart: { image: png } })).status, 401);++ });++++ await check('a consent records the feature, text version, signature and server time', async () => {++ const texts = await t.get('/api/v1/faces/consents/text');++ assert.strictEqual(texts.status, 200);++ assert.deepStrictEqual(Object.keys(texts.json()).sort(), ['cast', 'find', 'library', 'verify']);++++ const wrong = await t.get('/api/v1/faces/consents/verify', { as: ana, json: { signed_name: 'Ana', text_version: 'old' } });++ assert.strictEqual(wrong.status, 400);++ assert.strictEqual(wrong.json().code, 'faces.signature_required');++ assert.strictEqual((await t.get('/api/v1/faces/consents/verify', { as: ana, json: { text_version: t.ctx.faces.TEXT.verify.version } })).status, 400);++++ const castNoAdult = await consent(ana, 'cast', { channel: 'live:one' });++ assert.strictEqual(castNoAdult.status, 400);++ assert.strictEqual(castNoAdult.json().code, 'faces.adult_required');++ const region = await consent(ana, 'library', { region: 'FR' });++ assert.strictEqual(region.status, 403);++ assert.strictEqual(region.json().code, 'faces.region_disabled');++++ const verify = await consent(ana, 'verify');++ assert.strictEqual(verify.status, 200, verify.text);++ assert.strictEqual(verify.json().feature, 'verify');++ assert.strictEqual(verify.json().text_version, t.ctx.faces.TEXT.verify.version);++ assert.ok(Date.parse(verify.json().granted_at));++ const row = await db.maybe('SELECT * FROM face_consents WHERE subject=$1 AND feature=$2 AND revoked_at IS NULL', [ana.subject, 'verify']);++ assert.strictEqual(row.text_version, t.ctx.faces.TEXT.verify.version);++ assert.strictEqual(row.signature, 'Test Person');++ assert.strictEqual(row.revoked_at, null);++ assert.ok(Date.parse(row.granted_at));++++ assert.strictEqual((await consent(ana, 'find')).status, 200);++ assert.strictEqual((await consent(ana, 'cast', { channel: 'live:one', adult: true })).status, 200);++ assert.strictEqual((await consent(bo, 'cast', { channel: 'live:two', adult: true })).status, 200);++++ const crossSite = await t.get('/api/v1/faces/consents/verify', { as: ana, origin: 'https://evil.example', json: { signed_name: 'Ana', text_version: t.ctx.faces.TEXT.verify.version } });++ assert.strictEqual(crossSite.status, 403);++ assert.strictEqual(crossSite.json().code, 'vision.cross_site');++ });++++ await check('enrolment needs exactly one face and seals the template with AES-GCM', async () => {++ model.state.faces = [];++ assert.strictEqual((await enrol(ana, 'verify')).status, 422);++ oneFace();++ model.state.faces = [{ ...FACE }, { ...FACE, x: 200 }];++ assert.strictEqual((await enrol(ana, 'verify')).status, 422);++ oneFace();++++ vector([1, 0, 0, 0]);++ const out = await enrol(ana, 'verify');++ assert.strictEqual(out.status, 200, out.text);++ assert.strictEqual(out.json().enrolled, true);++ assert.strictEqual(out.json().liveness, 'not_checked');++ const row = await db.maybe('SELECT ciphertext FROM face_templates WHERE subject=$1 AND feature=$2', [ana.subject, 'verify']);++ assert.ok(row && row.ciphertext);++ assert.ok(!row.ciphertext.includes('1,0,0,0'), 'the vector is not plaintext');++ assert.deepStrictEqual(open(keyOf(KEY), row.ciphertext), [1, 0, 0, 0]);++ assert.deepStrictEqual(keyOf('k'.repeat(32)), Buffer.from('k'.repeat(32)));++ assert.deepStrictEqual(keyOf('ab'.repeat(32)), Buffer.from('ab'.repeat(32), 'hex'));++++ vector([0, 1, 0, 0]);++ assert.strictEqual((await enrol(ana, 'find')).status, 200);++ vector([1, 0, 0, 0]);++ assert.strictEqual((await enrol(ana, 'cast', { channel: 'live:one' })).status, 200);++ assert.strictEqual((await enrol(bo, 'cast', { channel: 'live:two' })).status, 200);++ });++++ await check('verify answers a score for the caller\'s own template and never an identity', async () => {++ vector([1, 0, 0, 0]);++ const yes = await verify(ana);++ assert.strictEqual(yes.status, 200, yes.text);++ const b = yes.json();++ assert.ok(b.score > 0.99);++ assert.strictEqual(b.match, true);++ assert.strictEqual(b.threshold, t.config.faces.threshold);++ assert.strictEqual(b.liveness, 'not_checked');++ assert.deepStrictEqual(Object.keys(b).sort(), ['liveness', 'match', 'score', 'threshold']);++ assert.ok(!yes.text.includes(ana.subject));++ assert.ok(!yes.text.includes(bo.subject));++++ vector([0, 1, 0, 0]);++ const no = await verify(ana);++ assert.strictEqual(no.status, 200, no.text);++ assert.strictEqual(no.json().match, false);++ assert.ok(no.json().score < t.config.faces.threshold);++ assert.strictEqual((await verify(bo)).status, 403);++ });++++ await check('self search reads only the caller\'s own encrypted upload and returns private links', async () => {++ vector([0, 1, 0, 0]);++ assert.strictEqual((await consent(bo, 'find')).status, 200);++ assert.strictEqual((await enrol(bo, 'find')).status, 200);++ const up = await upload(ana);++ assert.strictEqual(up.status, 200, up.text);++ assert.match(up.json().private_link, /^\/api\/v1\/faces\/media\/fme_/);++ assert.strictEqual(up.json().face_count, 1);++ const id = up.json().private_link.split('/').pop();++ const row = await db.maybe('SELECT image_ciphertext,vectors_ciphertext FROM face_media WHERE id=$1', [id]);++ assert.ok(!row.image_ciphertext.includes(png.toString('base64')));++ assert.deepStrictEqual(open(keyOf(KEY), row.image_ciphertext, true), png);++ assert.deepStrictEqual(open(keyOf(KEY), row.vectors_ciphertext), [[0, 1, 0, 0]]);++++ const own = await t.get(`/api/v1/faces/media/${id}`, { as: ana });++ assert.strictEqual(own.status, 200);++ assert.deepStrictEqual(own.buffer, png);++ assert.strictEqual((await t.get(`/api/v1/faces/media/${id}`, { as: bo })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/faces/media/${id}`, { as: cy })).status, 404, 'ownership is checked before consent');++ assert.strictEqual((await t.get(`/api/v1/faces/media/${id}`)).status, 401);++++ vector([0, 1, 0, 0]);++ const found = await t.get('/api/v1/faces/find', { as: ana, json: {} });++ assert.strictEqual(found.status, 200, found.text);++ assert.strictEqual(found.json().results.length, 1);++ assert.strictEqual(found.json().results[0].private_link, `/api/v1/faces/media/${id}`);++ assert.ok(!found.text.includes(ana.subject));++ assert.ok(!found.text.includes(bo.subject));++ });++++ await check('opting out of self search deletes its template and stored uploads at once', async () => {++ await vector([0, 1, 0, 0]);++ const off = await t.get('/api/v1/faces/consents/find', { method: 'DELETE', as: ana });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(off.json().erased, true);++ assert.strictEqual(await count('face_templates', ana.subject) >= 1, true, 'verify template remains');++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='find'", [ana.subject])), 0);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_media WHERE subject=$1", [ana.subject])), 0);++ assert.strictEqual((await t.get('/api/v1/faces/find', { as: ana, json: {} })).status, 403);++ });++++ await check('library grouping is region-gated, unlabelled and correctable; turning it off erases', async () => {++ assert.strictEqual((await consent(ana, 'library', { region: 'US-IL' })).status, 200);++ assert.strictEqual((await t.get('/api/v1/faces/library/cluster', { as: ana, json: { region: 'FR' } })).status, 403);++++ model.state.faces = [{ ...FACE }, { ...FACE, x: 200 }];++ model.state.vector = [0, 0, 0, 1];++ model.state.vectors = [[1, 0, 0, 0], [0, 1, 0, 0]];++ model.state.at = 0;++ const first = await upload(ana);++ assert.strictEqual(first.status, 200, first.text);++ assert.strictEqual(first.json().face_count, 2);++++ oneFace();++ vector([1, 0, 0, 0]);++ const second = await upload(ana);++ assert.strictEqual(second.status, 200, second.text);++++ const clustered = await t.get('/api/v1/faces/library/cluster', { as: ana, json: { region: 'US-IL' } });++ assert.strictEqual(clustered.status, 200, clustered.text);++ const groups = clustered.json();++ assert.strictEqual(groups.length, 3);++ assert.ok(groups.every((g) => g.label === null));++ assert.ok(groups.every((g) => !('subject' in g)));++ assert.strictEqual(new Set(groups.map((g) => g.group_id)).size, 2);++++ const [a, b] = groups;++ const excluded = await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'exclude', ids: [a.id] } });++ assert.strictEqual(excluded.status, 200, excluded.text);++ assert.strictEqual(excluded.json().updated, 1);++ assert.strictEqual((await db.maybe('SELECT excluded FROM face_clusters WHERE id=$1', [a.id])).excluded, true);++++ const open = groups.filter((g) => g.id !== a.id);++ assert.ok(open.length >= 2);++ const merged = await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'merge', ids: [open[0].id, open[1].id] } });++ assert.strictEqual(merged.status, 200, merged.text);++ const same = await db.many('SELECT group_id FROM face_clusters WHERE id=ANY($1::text[])', [[open[0].id, open[1].id]]);++ assert.strictEqual(new Set(same.map((r) => r.group_id)).size, 1);++++ const split = await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'split', ids: [b.id] } });++ assert.strictEqual(split.status, 200, split.text);++ const before = await db.maybe('SELECT group_id FROM face_clusters WHERE id=$1', [b.id]);++ assert.ok(before.group_id);++++ const off = await t.get('/api/v1/faces/consents/library', { method: 'DELETE', as: ana });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_clusters WHERE subject=$1", [ana.subject])), 0);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_media WHERE subject=$1", [ana.subject])), 0);++ assert.strictEqual((await t.get('/api/v1/faces/library/groups', { as: ana })).status, 403);++ });++++ await check('a cast channel matches only its own roster; revocation removes matches and past tags', async () => {++ vector([1, 0, 0, 0]);++ const one = liveToken(t, 'live:one');++ const matchOne = await t.get('/api/v1/faces/cast/match', { bearer: one, multipart: { fields: { channel: 'live:one', media_ref: 'media:stream-1' }, image: png } });++ assert.strictEqual(matchOne.status, 200, matchOne.text);++ assert.strictEqual(matchOne.json().matches.length, 1);++ assert.strictEqual(matchOne.json().matches[0].subject, ana.subject);++ assert.ok(!matchOne.text.includes(bo.subject));++ assert.strictEqual(matchOne.json().liveness, 'not_checked');++++ const two = liveToken(t, 'live:two');++ const matchTwo = await t.get('/api/v1/faces/cast/match', { bearer: two, multipart: { fields: { channel: 'live:two', media_ref: 'media:stream-2' }, image: png } });++ assert.strictEqual(matchTwo.status, 200, matchTwo.text);++ assert.strictEqual(matchTwo.json().matches.length, 1);++ assert.strictEqual(matchTwo.json().matches[0].subject, bo.subject);++ assert.ok(!matchTwo.text.includes(ana.subject));++++ const wrong = await t.get('/api/v1/faces/cast/match', { bearer: one, multipart: { fields: { channel: 'live:two', media_ref: 'media:stream-2' }, image: png } });++ assert.strictEqual(wrong.status, 403);++++ const tags = await t.get('/api/v1/faces/cast/tags', { as: ana });++ assert.strictEqual(tags.status, 200);++ assert.strictEqual(tags.json().length, 1);++ assert.strictEqual(tags.json()[0].channel, 'live:one');++ assert.ok(!tags.text.includes(bo.subject));++++ const off = await t.get('/api/v1/faces/consents/cast', { method: 'DELETE', as: ana, json: { channel: 'live:one' } });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual((await t.get('/api/v1/faces/cast/tags', { as: ana })).json().length, 0);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_tags WHERE subject=$1", [ana.subject])), 0);++ const after = await t.get('/api/v1/faces/cast/match', { bearer: one, multipart: { fields: { channel: 'live:one', media_ref: 'media:stream-1' }, image: png } });++ assert.strictEqual(after.status, 200);++ assert.deepStrictEqual(after.json().matches, []);++ const still = await t.get('/api/v1/faces/cast/match', { bearer: two, multipart: { fields: { channel: 'live:two', media_ref: 'media:stream-2' }, image: png } });++ assert.strictEqual(still.json().matches.length, 1, 'the other channel is unaffected');++ await t.get('/api/v1/faces/consents/cast', { method: 'DELETE', as: bo, json: { channel: 'live:two' } });++ });++++ await check('the retention job erases expired templates and old consent records, not recent ones', async () => {++ vector([1, 0, 0, 0]);++ assert.strictEqual((await consent(cy, 'verify')).status, 200);++ assert.strictEqual((await enrol(cy, 'verify')).status, 200);++ await db.exec("UPDATE face_consents SET last_used_at = now() - interval '4 years' WHERE subject=$1 AND feature='verify'", [cy.subject]);++ const first = await t.ctx.faces.retention();++ assert.ok(first.expired >= 1);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [cy.subject])), 0);++ assert.ok((await db.maybe('SELECT revoked_at FROM face_consents WHERE subject=$1 AND feature=$2', [cy.subject, 'verify'])).revoked_at);++ await db.exec("UPDATE face_consents SET purpose_ends_at = now() - interval '2 years' WHERE subject=$1 AND feature='verify' AND revoked_at IS NOT NULL", [cy.subject]);++ const second = await t.ctx.faces.retention();++ assert.ok(second.consent_records_deleted >= 1);++ assert.strictEqual(await count('face_consents', cy.subject), 0);++++ assert.strictEqual((await consent(eve, 'verify')).status, 200);++ assert.strictEqual((await enrol(eve, 'verify')).status, 200);++ await db.exec("UPDATE face_consents SET last_used_at = now() - interval '2 years' WHERE subject=$1 AND feature='verify'", [eve.subject]);++ await t.ctx.faces.retention();++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [eve.subject])), 1);++ });++++ await check('/people and /privacy explain the consent model with plain vi- markup', async () => {++ const anon = await t.get('/people');++ assert.strictEqual(anon.status, 200);++ for (const id of ['vi-people', 'vi-verify', 'vi-find', 'vi-cast', 'vi-library']) assert.ok(anon.text.includes(`id="${id}"`), id);++ assert.match(anon.text, /Sign in with your Network account/);++++ const signed = await t.get('/people', { as: ana });++ assert.strictEqual(signed.status, 200);++ assert.ok(signed.text.includes('/people/consent/verify'));++ assert.ok(signed.text.includes('/people/enrol/verify'));++ assert.match(signed.text, /role="switch"/);++ assert.match(signed.text, /name="text_version"/);++ assert.match(signed.text, /name="region"/);++++ const privacy = await t.get('/people/privacy');++ assert.strictEqual(privacy.status, 200);++ assert.ok(privacy.text.includes('id="vi-privacy"'));++ assert.match(privacy.text, /three years/);++ assert.match(privacy.text, /What never happens/);++ assert.match(privacy.text, /not checked/);++ const policy = await t.get('/privacy');++ assert.strictEqual(policy.status, 200);++ assert.match(policy.text, /Privacy Policy/);++++ const llms = await t.get('/llms.txt');++ assert.ok(!/No face recognition/i.test(llms.text));++ assert.match(llms.text, /separate consent/i);++ const home = await t.get('/');++ assert.ok(!/No face recognition/i.test(home.text));++ assert.ok(home.text.includes('/people'));++ assert.ok(home.text.includes('/people/privacy'));++ });++++ await check('opting out of verification deletes its template at once', async () => {++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [ana.subject])), 1);++ const off = await t.get('/api/v1/faces/consents/verify', { method: 'DELETE', as: ana });++ assert.strictEqual(off.status, 200, off.text);++ assert.strictEqual(off.json().erased, true);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [ana.subject])), 0);++ assert.ok((await db.maybe('SELECT revoked_at FROM face_consents WHERE subject=$1 AND feature=$2', [ana.subject, 'verify'])).revoked_at);++ assert.strictEqual((await verify(ana)).status, 403);++ });++++ await check('ADR-033 export and deletion include every face table', async () => {++ vector([1, 0, 0, 0]);++ assert.strictEqual((await consent(dee, 'verify')).status, 200);++ assert.strictEqual((await enrol(dee, 'verify')).status, 200);++ assert.strictEqual((await consent(dee, 'cast', { channel: 'live:dee', adult: true })).status, 200);++ assert.strictEqual((await enrol(dee, 'cast', { channel: 'live:dee' })).status, 200);++ assert.strictEqual((await consent(dee, 'library', { region: 'US-IL' })).status, 200);++ oneFace();++ vector([1, 0, 0, 0]);++ assert.strictEqual((await upload(dee)).status, 200);++ assert.strictEqual((await t.get('/api/v1/faces/library/cluster', { as: dee, json: { region: 'US-IL' } })).status, 200);++ const tag = await t.get('/api/v1/faces/cast/match', { bearer: liveToken(t, 'live:dee'), multipart: { fields: { channel: 'live:dee', media_ref: 'media:dee' }, image: png } });++ assert.strictEqual(tag.status, 200, tag.text);++++ const part = await t.ctx.accountData.exportPart(dee.subject);++ const files = part.files.map((f) => f.name).sort();++ for (const file of ['face-clusters.json', 'face-consents.json', 'face-media.json', 'face-tags.json', 'face-templates.json']) assert.ok(files.includes(file), file);++ assert.ok(part.files.every((f) => f.content.every((row) => row.subject === dee.subject)));++ assert.ok(!JSON.stringify(part).includes(ana.subject));++ const template = part.files.find((f) => f.name === 'face-templates.json').content[0];++ assert.ok(!template.ciphertext.includes('1,0,0,0'));++++ const erased = await t.ctx.accountData.erase([dee.subject]);++ for (const table of ['face_tags', 'face_clusters', 'face_media', 'face_templates', 'face_consents']) {++ assert.ok(erased.erased[table] >= 1, `${table} erased`);++ assert.strictEqual(await count(table, dee.subject), 0, `${table} empty`);++ }++ assert.ok((await count('face_consents', ana.subject)) >= 1);++ });++++ await check('readiness reports a missing face key as degraded, and the rest of Vision stays ready', async () => {++ const ready = await t.get('/api/ready');++ assert.strictEqual(ready.status, 200, ready.text);++ assert.strictEqual(ready.json().checks.face_models.status, 'ok');++++ const noKey = fakeFacesModel();++ const u = await boot({ env: { VISION_FACE_KEY: null, VISION_FACE_LIBRARY_REGIONS: 'US-IL' }, facesModel: noKey });++ try {++ const r = await u.get('/api/ready');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().ready, true);++ assert.ok(r.json().degraded.includes('face_models'));++ assert.strictEqual(r.json().checks.face_models.status, 'fail');++ assert.match(r.text, /VISION_FACE_KEY/);++ const detect = await u.get('/api/v1/faces/detect', { multipart: { image: png } });++ assert.strictEqual(detect.status, 200, 'detect needs YuNet, not the key');++ } finally { await u.close(); }++ });++ } finally {++ await t.close();++ }++ done();++})();@@@ -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, ocr: opts.ocr, ai: opts.aiClient, clip: opts.clip, detector: opts.detector });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ocr: opts.ocr, ai: opts.aiClient, facesModel: opts.facesModel, clip: opts.clip, detector: opts.detector });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}`;@@@ -117,10 +117,13 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</styconst map = await t.get('/sitemap.xml');assert.strictEqual(map.status, 200);assert.ok(map.text.includes('https://openvibe.vision/ui-check'));++ assert.ok(map.text.includes('https://openvibe.vision/people'));++ assert.ok(map.text.includes('https://openvibe.vision/privacy'));const llms = await t.get('/llms.txt');assert.strictEqual(llms.status, 200);assert.ok(llms.text.includes('OpenVibe.Vision'));-- assert.match(llms.text, /No face recognition/);++ assert.match(llms.text, /separate consent/);++ assert.doesNotMatch(llms.text, /No face recognition/);});} finally {await t.close();
@@ -59,6 +59,16 @@ VISION_TESSERACT=tesseract
@@ -59,6 +59,16 @@ VISION_TESSERACT=tesseract
#VISION_IMAGE_TIMEOUT_MS=10000
#VISION_IMAGE_TIMEOUT_MS=10000
#VISION_ALLOW_PRIVATE_URLS=0
#VISION_ALLOW_PRIVATE_URLS=0
+# Local face models live in DATA_DIR/models. Run npm run models:faces to fetch pinned, hashed weights.
+# Without weights/key, only face routes degrade; /api/ready reports the missing component.
+# VISION_FACE_KEY must be a random 32-byte AES key encoded as base64. Never rotate it without re-encrypting stored rows.
+DATA_DIR=
+VISION_FACE_KEY=
+# Comma-separated region codes; empty means library grouping is off everywhere.
+VISION_FACE_LIBRARY_REGIONS=
+#VISION_FACE_MATCH_THRESHOLD=0.363
+#VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN=30
+
# The UI check: HTML, URL or screenshot. It never executes JavaScript and never fetches remote stylesheets.
# The UI check: HTML, URL or screenshot. It never executes JavaScript and never fetches remote stylesheets.
#VISION_UI_MAX_HTML_KB=1024
#VISION_UI_MAX_HTML_KB=1024
#VISION_UI_TIMEOUT_MS=10000
#VISION_UI_TIMEOUT_MS=10000
@@ -20,18 +20,35 @@ The first version: see with the best model for the job.
@@ -20,18 +20,35 @@ The first version: see with the best model for the job.
estimates boxes and inline CSS to find overflow, cut-off text, WCAG contrast failures, overlapping positioned
estimates boxes and inline CSS to find overflow, cut-off text, WCAG contrast failures, overlapping positioned
boxes and layering faults (`z-index` without position, a full-viewport fixed box). The screenshot pass uses the
boxes and layering faults (`z-index` without position, a full-viewport fixed box). The screenshot pass uses the
local OCR boxes and sharp pixels. Findings are data: kind, severity, selector, detail, evidence. Design's Arena
local OCR boxes and sharp pixels. Findings are data: kind, severity, selector, detail, evidence. Design's Arena
- calls it with `vision.ui_check`.
+ calls it with `vision.ui.check`.
- **The AI gateway.** Vision calls OpenVibe.AI's OpenAI-compatible endpoint with a first-party service token
- **The AI gateway.** Vision calls OpenVibe.AI's OpenAI-compatible endpoint with a first-party service token
(`ai.run.create`, namespace `ai.*`). AI's routing, quotas, cache, provenance and cost accounting apply. Vision holds
(`ai.run.create`, namespace `ai.*`). AI's routing, quotas, cache, provenance and cost accounting apply. Vision holds
no provider key, adds no provider and calls no paid provider directly.
no provider key, adds no provider and calls no paid provider directly.
-- **No looks, no faces.** Public AI text goes through `noLooks`; an answer that names a person's looks is replaced by
- a neutral sentence. Face recognition is not implemented and is not called, ever.
-- **Privacy.** No image bytes are stored: an answer keeps a sha256, the media type, the cleaned text and the model
- provenance. Answers, compare samples and UI checks are exported and erased with the person's account.
+- **No looks in AI text.** Public AI text goes through `noLooks`; an answer that names a person's looks is replaced by
+ a neutral sentence.
+- **Faces, by purpose, with signed consent.** `POST /api/v1/faces/detect` and `/blur` are open local YuNet privacy
+ tools with no templates or tracking. Self verification, self search, adult cast tagging and private library
+ grouping each need their own signed consent (`face_consents`), a local SFace template encrypted with
+ `VISION_FACE_KEY` (AES-256-GCM), and immediate erasure on opt-out. A cast matcher reads only the named channel's
+ roster and revoking deletes past tags. Library grouping is region-gated and unknown faces stay unlabelled.
+ `POST /api/v1/faces/verify` returns a score, threshold and `liveness: "not_checked"`, never an identity. The daily
+ retention job (off in `VISION_DRILL`) expires templates three years after last use. `npm run models:faces` pins the
+ YuNet and SFace downloads by URL and SHA-256. Pages: `/people` and `/people/privacy`; `/privacy` stays the shared
+ legal policy.
+- **Network tools, wave 1.** `POST /api/v1/alt` (routed editable alt text), `POST /api/v1/index` and
+ `GET`/`POST /api/v1/find` (owner-scoped CLIP search), `POST /api/v1/receipt` (local Tesseract first),
+ `POST /api/v1/product` (CLIP vector plus configured offers), `POST /api/v1/watch` (DETR boxes and scene change)
+ and `POST /api/v1/moments` (scene histogram plus CLIP prompts). Each answer carries a receipt. `npm run
+ models:fetch` downloads pinned quantized CLIP and DETR weights; missing weights degrade to an honest 503.
+- **Privacy.** General answers store no image bytes: an answer keeps a sha256, the media type, the cleaned text and
+ the model provenance. Opt-in face uploads, vectors and templates are encrypted. Answers, compare samples, UI checks
+ and every face row are exported and erased with the person's account.
- **The service.** Network sign-in as OAuth client `vision` (PKCE S256, `vision_at`/`vision_rt`/`vision_oauth`),
- **The service.** Network sign-in as OAuth client `vision` (PKCE S256, `vision_at`/`vision_rt`/`vision_oauth`),
truthful readiness, Prometheus metrics on loopback, discovery, a systemd unit, an nginx vhost and CI. Pages are
truthful readiness, Prometheus metrics on loopback, discovery, a systemd unit, an nginx vhost and CI. Pages are
server-rendered with stable `vi-` ids/classes; `public/css/vision.css` and `public/js/vision.js` are
server-rendered with stable `vi-` ids/classes; `public/css/vision.css` and `public/js/vision.js` are
one-comment placeholders for the main session.
one-comment placeholders for the main session.
- **Tests.** Migration, SSO, API, OCR, compare/benchmarks, the UI check (including a planted overflow), noLooks,
- **Tests.** Migration, SSO, API, OCR, compare/benchmarks, the UI check (including a planted overflow), noLooks,
- pages/cache and account export/deletion, each in its own process on a temporary PGlite database with a mock
- Network, a mock OpenVibe.AI and local image/page fixtures.
+ pages/cache, account export/deletion, faces with a fake ONNX model (consent gates, roster-only matching,
+ encryption at rest, opt-out, retention, region gate and export/deletion), and the network tools with fake CLIP,
+ DETR and OCR, each in its own process on a temporary PGlite database with a mock Network, a mock OpenVibe.AI and
+ local image/page fixtures.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> See with the best model for the job.
> See with the best model for the job.
-**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Status:** v1, the §5.8b faces work and the network tools written and tested (see [STATUS.json](STATUS.json)).
**Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION`
**Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION`
**License:** AGPL-3.0-only (same as every OpenVibe service).
**License:** AGPL-3.0-only (same as every OpenVibe service).
@@ -10,7 +10,7 @@ OpenVibe.Vision answers questions about a picture. The cheapest engine goes firs
@@ -10,7 +10,7 @@ OpenVibe.Vision answers questions about a picture. The cheapest engine goes firs
answers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be run
answers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be run
through the local reader and up to three AI model hints side by side, with the model that actually answered, its
through the local reader and up to three AI model hints side by side, with the model that actually answered, its
cost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns
cost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns
-overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No face recognition, ever.**
+overflow, cut-off text, low contrast, overlap and layering faults as JSON. Face tools run locally; identifying matches require separate, purpose-scoped consent.
## v1 scope (plan §5.8)
## v1 scope (plan §5.8)
@@ -23,13 +23,31 @@ overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No
@@ -23,13 +23,31 @@ overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No
cost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text.
cost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text.
- **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding
- **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding
`vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously.
`vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously.
-- **No faces, no looks.** No face recognition is implemented or called; the prompt also forbids naming or identifying a person. Public AI text is passed through `noLooks`;
+- **Consent-scoped faces, no looks in AI text.** Face detection and blur are open privacy tools. Matching requires a separate signed consent for each purpose. Public AI text is passed through `noLooks`;
an answer that names a person's looks is replaced by a neutral sentence.
an answer that names a person's looks is replaced by a neutral sentence.
-- **Privacy.** No image bytes are stored: a sha256, media type and byte count are. Answers, compare samples and UI
- checks are exported and erased with the person's account.
+- **Privacy.** General answers store no image bytes: a sha256, media type and byte count are stored. Opt-in face uploads, vectors and templates are encrypted and private. Answers, compare samples, UI checks and every face row are exported and erased with the person's account.
- **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has no
- **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has no
public records, so it writes no Search document and has no outbox.
public records, so it writes no Search document and has no outbox.
+## Faces with consent (§5.8b)
+
+`npm run models:faces` downloads pinned OpenCV Zoo YuNet and SFace weights into `DATA_DIR/models`, checking a SHA-256 before installation. They are not in git. YuNet's directory is MIT licensed; SFace's directory is Apache 2.0 licensed. **Open question before production distribution:** [SFace weight and training-data provenance](https://github.com/opencv/opencv_zoo/issues/318) needs upstream clarification. The service starts without weights or `VISION_FACE_KEY`; `/api/ready` reports an optional degraded face check. Detect/blur need only YuNet; enrolment, verification and cast matching also need SFace and the key, and answer 503 until they exist. All face inference uses `onnxruntime-node` on this host, never a cloud model.
+
+The key is `VISION_FACE_KEY` (base64 encoding of 32 random bytes). AES-256-GCM encrypts templates, Vision uploads and face vectors under the person's Network subject. Losing this key makes stored face data unreadable; rotate it only with a re-encryption migration. `VISION_FACE_MATCH_THRESHOLD` defaults to the OpenCV SFace cosine example threshold 0.363 and needs calibration before consequential use. `VISION_FACE_LIBRARY_REGIONS` is a comma-separated allowlist; empty disables library grouping everywhere. `VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN` limits anonymous detect/blur, alongside the API address limit. No face template enters Search, model prompts or Bench.
+
+The four signed text versions are exposed by `GET /api/v1/faces/consents/text`. A Network person signs one purpose at a time by sending `signed_name` and the current `text_version` to `POST /api/v1/faces/consents/:feature` (`verify`, `find`, `cast`, `library`). Cast also needs `adult: true` and a named `channel`; library needs an allowed `region`. The signed name and server time are kept in `face_consents`. `DELETE /api/v1/faces/consents/:feature` revokes and immediately deletes that purpose's template; cast revocation also removes past tags. `/people` has the same choices and `/people/privacy` explains face retention. The site's shared legal privacy policy remains at `/privacy`.
+
+| Route | Result and scope |
+|---|---|
+| `POST /api/v1/faces/detect`, `/blur` | Anyone; boxes/count or image/png redaction, no templates or tracking. |
+| `POST /api/v1/faces/enrol/:feature` | Network person only; one face image after that feature's consent (`verify`, `find`, `cast`). |
+| `POST /api/v1/faces/verify` | Person only; `{score,match,threshold,liveness:"not_checked"}` against their own verify template. A match alone must never unlock anything: use a tested liveness component, second factor and non-biometric route before any consequential use. |
+| `POST /api/v1/faces/media`, `GET /api/v1/faces/media/:id`, `POST /api/v1/faces/find` | Person only; encrypted own Vision uploads, owner-only links and self-search matches. Media objects are deferred until a source-owner grant and open-time access check exist. |
+| `POST /api/v1/faces/cast/match` | Live service token with `vision.face.cast.match` and signed `channel_id` claim; compares only that channel's enrolled roster. Requires on-stream notice from Live. Person can inspect their own tags at `GET /api/v1/faces/cast/tags`. |
+| `POST /api/v1/faces/library/cluster`, `GET /api/v1/faces/library/groups`, `POST /api/v1/faces/library/correct` | Person only, separate opt-in and allowed region. Unknown groups have no label. Corrections: `exclude`, `merge`, `split`; switching off erases groups. |
+
+`face_consents`, `face_templates`, `face_media`, `face_tags` and `face_clusters` are created by `migrations/0003_faces.sql`. A daily job (off in `VISION_DRILL`) deletes inactive templates at three years since last use and revoked consent audit rows within one year of purpose end. ADR-033 export and account deletion include all five tables. The account export carries encrypted template and upload ciphertext; it does not expose the server key.
+
## What runs where
## What runs where
| Step | Where | Cost | Leaves the host? |
| Step | Where | Cost | Leaves the host? |
@@ -38,6 +56,7 @@ overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No
@@ -38,6 +56,7 @@ overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No
| OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no |
| OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no |
| UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) |
| UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) |
| UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no |
| UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no |
+| Face detect, blur and consented matching | this host, YuNet + SFace + ONNX Runtime | 0 | no |
| Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI |
| Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI |
| OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent |
| OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent |
| Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI |
| Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI |
@@ -54,8 +73,13 @@ outbound capability; AI's quotas and its own route decide whether a run is allow
@@ -54,8 +73,13 @@ outbound capability; AI's quotas and its own route decide whether a run is allow
| `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `0001_initial.sql` |
| `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `0001_initial.sql` |
| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
+| `face_consents` | subject, feature `verify\|find\|cast\|library`, channel scope, signed text version/name, granted_at, revoked_at, last_used_at, purpose_ends_at | `migrations/0003_faces.sql` |
+| `face_templates` | subject, feature, scope, AES-GCM ciphertext of the SFace vector, created/last_used times | `0003_faces.sql` |
+| `face_media` | owner, AES-GCM ciphertext of an opt-in Vision upload and its face vectors, media type | `0003_faces.sql` |
+| `face_tags` | consenting subject, channel, private media reference and time; removed on cast revocation | `0003_faces.sql` |
+| `face_clusters` | owner, media, encrypted vector, group id, optional label (null while unknown), excluded flag | `0003_faces.sql` |
-No table stores image bytes, a token or a provider key.
+General answer tables store no image bytes, token or provider key. The opt-in face library stores encrypted uploads and vectors.
## Does not own
## Does not own
@@ -68,7 +92,7 @@ No table stores image bytes, a token or a provider key.
@@ -68,7 +92,7 @@ No table stores image bytes, a token or a provider key.
## Capabilities and grants
## Capabilities and grants
-The service offers four capabilities. The main session registers their manifests and grants them; v1 checks the
+The service offers eleven capabilities. The main session registers their manifests and grants them; v1 checks the
grant list directly, so a token that already carries one works.
grant list directly, so a token that already carries one works.
| Capability | Routes | Typical caller |
| Capability | Routes | Typical caller |
@@ -77,6 +101,13 @@ grant list directly, so a token that already carries one works.
@@ -77,6 +101,13 @@ grant list directly, so a token that already carries one works.
| `vision.ocr` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person |
| `vision.ocr` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person |
| `vision.compare` | `POST /api/v1/compare` | a first-party service or a person |
| `vision.compare` | `POST /api/v1/compare` | a first-party service or a person |
| `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person |
| `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person |
+| `vision.face.cast.match` | `POST /api/v1/faces/cast/match` (a signed `channel_id` claim must match the request) | **OpenVibe.Live**, or a person inspecting their own tags |
+| `vision.alt` | `POST /api/v1/alt` | Pics, Media and Chat |
+| `vision.find` | `POST /api/v1/index`, `GET`/`POST /api/v1/find` (owner user token) | Media and Search |
+| `vision.receipt` | `POST /api/v1/receipt` | Food |
+| `vision.product` | `POST /api/v1/product` | Deals |
+| `vision.watch` | `POST /api/v1/watch` (owner user token) | Cam |
+| `vision.moments` | `POST /api/v1/moments` (owner user token) | Live and Media |
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.
@@ -125,7 +156,10 @@ Answer (abridged):
@@ -125,7 +156,10 @@ Answer (abridged):
Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mode`, `vision.question_required`,
Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mode`, `vision.question_required`,
`vision.image_required`, `vision.image_too_large`, `vision.image_private_url`, `vision.image_unsupported`,
`vision.image_required`, `vision.image_too_large`, `vision.image_private_url`, `vision.image_unsupported`,
-`vision.ocr_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`.
+`vision.ocr_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`, and the face codes
+`faces.consent_required`, `faces.signature_required`, `faces.adult_required`, `faces.region_disabled`,
+`faces.enrolment_required`, `faces.one_face_required`, `faces.model_unavailable`, `faces.key_unavailable`,
+`faces.live_grant_required`.
## Pages
## Pages
@@ -142,6 +176,12 @@ person is `private, no-store`.
@@ -142,6 +176,12 @@ person is `private, no-store`.
| `/ui-check` | HTML, URL or screenshot; inline for a guest, saved at `/ui/:id` for a person |
| `/ui-check` | HTML, URL or screenshot; inline for a guest, saved at `/ui/:id` for a person |
| `/a/:id` | one saved answer (owner or staff) |
| `/a/:id` | one saved answer (owner or staff) |
| `/ui/:id` | one saved UI check (owner or staff) |
| `/ui/:id` | one saved UI check (owner or staff) |
+| `/find` | private visual search over a person's own indexed images |
+| `/read` | the receipt reader |
+| `/watch` | watched objects and scene change in a camera still |
+| `/people` | the four face purposes, each with its own signed switch, enrolment and off control |
+| `/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 |
| `/developers` | the API, capabilities and what leaves the host |
| 404 | the not-found page |
| 404 | the not-found page |
@@ -170,7 +210,8 @@ node scripts/proof.js # local OCR + a planted UI overflow, no network and
@@ -170,7 +210,8 @@ node scripts/proof.js # local OCR + a planted UI overflow, no network and
The tests use a mock Network, a mock OpenVibe.AI and local image/page fixtures: migration, SSO and readiness, the
The tests use a mock Network, a mock OpenVibe.AI and local image/page fixtures: migration, SSO and readiness, the
API and its capability guards, local OCR and its fallback, compare and benchmark aggregation, the UI check (a
API and its capability guards, local OCR and its fallback, compare and benchmark aggregation, the UI check (a
planted overflow, cut-off line, low contrast, overlap and layering fault), noLooks, pages/cache, account
planted overflow, cut-off line, low contrast, overlap and layering fault), noLooks, pages/cache, account
-export/deletion and the image SSRF guard.
+export/deletion, the image SSRF guard, and faces with a fake ONNX model (all four consent gates, opt-out erasure,
+cast-roster-only matching, AES-GCM at rest, the retention job, the region gate and ADR-033 export/deletion).
## Deploy
## Deploy
@@ -25,6 +25,9 @@ domains) and every repository under github.com/OpenVibers. Machine-readable cont
@@ -25,6 +25,9 @@ domains) and every repository under github.com/OpenVibers. Machine-readable cont
- UI checks: source (html, url or screenshot), the viewport, the findings and the summary. Never the screenshot.
- UI checks: source (html, url or screenshot), the viewport, the findings and the summary. Never the screenshot.
- A person's Network session tokens only in httpOnly cookies (`vision_at`, `vision_rt`); no token is stored in the
- A person's Network session tokens only in httpOnly cookies (`vision_at`, `vision_rt`); no token is stored in the
database, rendered into a page or logged.
database, rendered into a page or logged.
+- Face data, only after a separate consent: AES-256-GCM ciphertext of face templates, opt-in Vision uploads and
+ vectors; the signed consent record (typed name, text version and time), cast tags and private library groups.
+ Never sent to OpenVibe.AI, Search, model prompts or Bench.
- No provider key. AI calls use the service's own Network client-credentials token and OpenVibe.AI's routing.
- No provider key. AI calls use the service's own Network client-credentials token and OpenVibe.AI's routing.
## Image inputs and the UI check
## Image inputs and the UI check
@@ -39,11 +42,9 @@ The `/internal/events` account route is loopback only and refuses a proxied requ
@@ -39,11 +42,9 @@ The `/internal/events` account route is loopback only and refuses a proxied requ
`/internal` and `/metrics`. A service token is judged on its own signature, audience and capability grant; an
`/internal` and `/metrics`. A service token is judged on its own signature, audience and capability grant; an
unknown or forged token is refused, never downgraded to the session.
unknown or forged token is refused, never downgraded to the session.
-## No faces
+## Faces with scoped consent
-Vision never identifies or recognises faces, never names a person, and never describes a person's looks in public AI text. The model prompt
-carries the rule and every AI answer is passed through `noLooks`; an answer that names looks is replaced. A report
-that either rule was broken is a security report for this repository.
+Detect and blur do not identify anyone. Face matching uses local YuNet/SFace ONNX models and separately signed, purpose-specific consent. Templates and opt-in uploads are encrypted with `VISION_FACE_KEY` (AES-256-GCM) and private; a person can withdraw a purpose and erase its template immediately. A cast matcher reads only the named channel's consenting roster, and revoking a grant stops matches and deletes past tags. Library grouping is off unless `VISION_FACE_LIBRARY_REGIONS` allows the region. Vision never identifies strangers on the open web, builds no public face index, and a match alone never unlocks anything (liveness is `not_checked` until a tested component exists). Public AI text never describes a person's looks: the model prompt carries the rule and every AI answer is passed through `noLooks`. A report that any rule was broken is a security report for this repository.
## Supported versions
## Supported versions
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
"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",
- "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp",
+ "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; 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",
"port": "5130",
"domain": "openvibe.vision",
"domain": "openvibe.vision",
"contracts": "openvibe-contracts v0.229.0",
"contracts": "openvibe-contracts v0.229.0",
@@ -20,14 +20,21 @@
@@ -20,14 +20,21 @@
"vision.ask",
"vision.ask",
"vision.ocr",
"vision.ocr",
"vision.compare",
"vision.compare",
- "vision.ui_check"
+ "vision.ui.check",
+ "vision.face.cast.match",
+ "vision.alt",
+ "vision.find",
+ "vision.receipt",
+ "vision.product",
+ "vision.watch",
+ "vision.moments"
],
],
"outboundCapabilities": [
"outboundCapabilities": [
"ai.run.create"
"ai.run.create"
],
],
"capabilitiesRegistered": false,
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"serviceManifestRegistered": false,
- "summary": "OpenVibe.Vision v1 sees with the best model for the job. A read question goes to the local Tesseract reader first; if the local engine cannot answer, OpenVibe.AI's routed vision model does, and every answer names the model, provider, route, cost and latency and says why it was chosen. A compare runs the same image through the local reader and up to three AI model hints side by side. The UI check reads HTML, a public URL or a screenshot locally and returns overflow, cut-off text, low contrast, overlap and layering findings as JSON; Design's Arena can call POST /api/v1/ui-check with a service token holding vision.ui_check. No image bytes are stored, no provider key exists here, and no face recognition is ever used.",
+ "summary": "OpenVibe.Vision reads images and checks UI faults, and its network tools produce alt text, owner-scoped private image search, receipt lines, product matches, camera detections and moments. Local face detection and blur use no identity; personal matching requires separately signed, purpose-scoped consent. General answers keep no image bytes. Opt-in face uploads and templates are encrypted, private and deletable. AI text never describes a person's looks.",
"works": [
"works": [
"Ask a picture: multipart upload, data URL or public image URL; task ask/describe/identify/count/translate/ocr; auto/local/ai modes",
"Ask a picture: multipart upload, data URL or public image URL; task ask/describe/identify/count/translate/ocr; auto/local/ai modes",
"Local-first OCR: Tesseract 5 on this host, word boxes and mean confidence, zero cost, an optional AI fallback only after an empty local pass",
"Local-first OCR: Tesseract 5 on this host, word boxes and mean confidence, zero cost, an optional AI fallback only after an empty local pass",
@@ -35,8 +42,11 @@
@@ -35,8 +42,11 @@
"Compare: local OCR plus one AI run per model hint, requested vs answered model, measured latency and AI cost, sample rows",
"Compare: local OCR plus one AI run per model hint, requested vs answered model, measured latency and AI cost, sample rows",
"UI check: static HTML/CSS pass (overflow, cut-off, contrast, overlap, layering), public URL pass with SSRF guard, screenshot pass through OCR boxes and sharp pixels",
"UI check: static HTML/CSS pass (overflow, cut-off, contrast, overlap, layering), public URL pass with SSRF guard, screenshot pass through OCR boxes and sharp pixels",
"No looks: public AI text is passed through noLooks; an answer that names looks is replaced by a neutral sentence",
"No looks: public AI text is passed through noLooks; an answer that names looks is replaced by a neutral sentence",
- "No face recognition: not implemented, not called, stated on the pages",
- "Answers, compare samples and UI checks stored without image bytes; account export/deletion over all three",
+ "Local face detection and blur through YuNet without templates or tracking; detect/blur are open and rate-limited",
+ "Consent-scoped SFace matching: verify (1:1), self search in own Vision uploads, adult cast rosters and region-gated private library grouping",
+ "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",
+ "Answers, compare samples, private index and UI checks stored without image bytes; account export/deletion over every table including faces and the private index",
"Network sign-in as OAuth client vision (PKCE S256, vision_at/vision_rt/vision_oauth), truthful readiness, loopback metrics",
"Network sign-in as OAuth client vision (PKCE S256, vision_at/vision_rt/vision_oauth), truthful readiness, loopback metrics",
"Server-rendered pages with stable vi- ids/classes, discovery artifacts, deploy unit and nginx vhost, CI"
"Server-rendered pages with stable vi- ids/classes, discovery artifacts, deploy unit and nginx vhost, CI"
],
],
@@ -47,6 +57,8 @@
@@ -47,6 +57,8 @@
"A real browser-rendered UI pass (JavaScript execution, getBoundingClientRect, computed styles); v1 is a documented static pass plus a screenshot pass",
"A real browser-rendered UI pass (JavaScript execution, getBoundingClientRect, computed styles); v1 is a documented static pass plus a screenshot pass",
"Search documents/outbox: v1 has no public records to index",
"Search documents/outbox: v1 has no public records to index",
"A per-person history page; saved answers are reachable by id for their owner and staff",
"A per-person history page; saved answers are reachable by id for their owner and staff",
+ "A tested liveness / presentation-attack detector: verify and cast enrolment return liveness \"not_checked\"",
+ "Media-object search through a source-owner grant: find yourself currently searches only the person's Vision uploads",
"The capability manifests and grants; the main session registers vision.* and grants them"
"The capability manifests and grants; the main session registers vision.* and grants them"
],
],
"deployed": false,
"deployed": false,
@@ -56,7 +68,8 @@
@@ -56,7 +68,8 @@
"OpenVibe.AI routed answers",
"OpenVibe.AI routed answers",
"model compare with cost and latency",
"model compare with cost and latency",
"UI check: overflow, cut-off, contrast, overlap, layering",
"UI check: overflow, cut-off, contrast, overlap, layering",
- "noLooks and no face recognition",
+ "noLooks and consent-scoped faces",
+ "network tools: alt, index/find, receipt, product, watch, moments",
"account export and deletion",
"account export and deletion",
"Network sign-in",
"Network sign-in",
"server-rendered pages",
"server-rendered pages",
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+-- phase: expand
+-- Face data stays private to a Network subject and never enters Search, prompts or Bench.
+CREATE TABLE face_consents (
+ id text PRIMARY KEY,
+ subject text NOT NULL,
+ feature text NOT NULL CHECK (feature IN ('verify', 'find', 'cast', 'library')),
+ scope text NOT NULL DEFAULT '',
+ text_version text NOT NULL,
+ signature text NOT NULL,
+ granted_at timestamptz NOT NULL,
+ revoked_at timestamptz,
+ last_used_at timestamptz NOT NULL,
+ purpose_ends_at timestamptz,
+ CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CHECK (length(scope) <= 160),
+ CHECK (length(signature) BETWEEN 1 AND 160)
+);
+CREATE UNIQUE INDEX face_consents_active ON face_consents (subject, feature, scope) WHERE revoked_at IS NULL;
+CREATE INDEX face_consents_retention ON face_consents (last_used_at, purpose_ends_at);
+
+CREATE TABLE face_templates (
+ subject text NOT NULL,
+ feature text NOT NULL,
+ scope text NOT NULL DEFAULT '',
+ ciphertext text NOT NULL,
+ created_at timestamptz NOT NULL,
+ last_used_at timestamptz NOT NULL,
+ PRIMARY KEY (subject, feature, scope)
+);
+-- A separate table holds only this person's own Vision uploads and encrypted face vectors.
+CREATE TABLE face_media (
+ id text PRIMARY KEY,
+ subject text NOT NULL,
+ image_ciphertext text NOT NULL,
+ image_mime text NOT NULL,
+ vectors_ciphertext text NOT NULL,
+ created_at timestamptz NOT NULL,
+ last_used_at timestamptz NOT NULL
+);
+CREATE INDEX face_media_owner ON face_media (subject, created_at DESC);
+
+CREATE TABLE face_tags (
+ id text PRIMARY KEY,
+ subject text NOT NULL,
+ channel text NOT NULL,
+ media_ref text NOT NULL,
+ created_at timestamptz NOT NULL,
+ last_used_at timestamptz NOT NULL
+);
+CREATE INDEX face_tags_channel ON face_tags (channel, created_at DESC);
+CREATE INDEX face_tags_subject ON face_tags (subject, channel);
+
+CREATE TABLE face_clusters (
+ id text PRIMARY KEY,
+ subject text NOT NULL,
+ media_id text NOT NULL REFERENCES face_media (id) ON DELETE CASCADE,
+ vector_ciphertext text NOT NULL,
+ group_id text NOT NULL,
+ label text,
+ excluded boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL,
+ last_used_at timestamptz NOT NULL
+);
+CREATE INDEX face_clusters_owner ON face_clusters (subject, created_at DESC);
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.0",
+ "onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
@@ -465,6 +466,73 @@
@@ -465,6 +466,73 @@
"url": "https://opencollective.com/libvips"
"url": "https://opencollective.com/libvips"
}
}
},
},
+ "node_modules/@huggingface/transformers/node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/@huggingface/transformers/node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
+ "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
+ "license": "MIT"
+ },
+ "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
+ "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "os": [
+ "win32",
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "global-agent": "^3.0.0",
+ "onnxruntime-common": "1.21.0",
+ "tar": "^7.0.1"
+ }
+ },
+ "node_modules/@huggingface/transformers/node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@huggingface/transformers/node_modules/sharp": {
"node_modules/@huggingface/transformers/node_modules/sharp": {
"version": "0.34.5",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -509,6 +577,18 @@
@@ -509,6 +577,18 @@
"@img/sharp-win32-x64": "0.34.5"
"@img/sharp-win32-x64": "0.34.5"
}
}
},
},
+ "node_modules/@huggingface/transformers/node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@img/colour": {
"node_modules/@img/colour": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1119,6 +1199,15 @@
@@ -1119,6 +1199,15 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/adm-zip": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
+ "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0"
+ }
+ },
"node_modules/ajv": {
"node_modules/ajv": {
"version": "8.20.0",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1729,17 +1818,15 @@
@@ -1729,17 +1818,15 @@
}
}
},
},
"node_modules/global-agent": {
"node_modules/global-agent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
- "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.6.tgz",
+ "integrity": "sha512-V9u791atI/bS7RryfRTK2oixDWo6/RCrIikjwuQ4kADPRZZml1C6LUJXc7y6qRhaAF8geo3bIvE/siAGJCw38A==",
"license": "BSD-3-Clause",
"license": "BSD-3-Clause",
"dependencies": {
"dependencies": {
- "boolean": "^3.0.1",
- "es6-error": "^4.1.1",
- "matcher": "^3.0.0",
- "roarr": "^2.15.3",
- "semver": "^7.3.2",
- "serialize-error": "^7.0.1"
+ "globalthis": "^1.0.2",
+ "matcher": "^4.0.0",
+ "semver": "^7.3.5",
+ "serialize-error": "^8.1.0"
},
},
"engines": {
"engines": {
"node": ">=10.0"
"node": ">=10.0"
@@ -2045,15 +2132,18 @@
@@ -2045,15 +2132,18 @@
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
"node_modules/matcher": {
"node_modules/matcher": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
- "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
+ "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"escape-string-regexp": "^4.0.0"
"escape-string-regexp": "^4.0.0"
},
},
"engines": {
"engines": {
"node": ">=10"
"node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
}
},
},
"node_modules/math-intrinsics": {
"node_modules/math-intrinsics": {
@@ -2217,15 +2307,15 @@
@@ -2217,15 +2307,15 @@
}
}
},
},
"node_modules/onnxruntime-common": {
"node_modules/onnxruntime-common": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
- "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.31.1.tgz",
+ "integrity": "sha512-F+nnYNiQtmNJ95V6R/isBJqiVpdANCrsMEg1OaF+Bkbl3GAtzWLR7Ng17Ld0QtYtYJyotOHPJzOQn1Y6PLZjpg==",
"license": "MIT"
"license": "MIT"
},
},
"node_modules/onnxruntime-node": {
"node_modules/onnxruntime-node": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
- "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.31.1.tgz",
+ "integrity": "sha512-ijnSghbDHVWaQ2nDPmVccPlCHI8kH1Mx1aJ4a/qzRP16bBcVs4E3EqZQKZCkhWnbqN1tuQWxagimFtDpbPYYQw==",
"hasInstallScript": true,
"hasInstallScript": true,
"license": "MIT",
"license": "MIT",
"os": [
"os": [
@@ -2234,9 +2324,9 @@
@@ -2234,9 +2324,9 @@
"linux"
"linux"
],
],
"dependencies": {
"dependencies": {
- "global-agent": "^3.0.0",
- "onnxruntime-common": "1.21.0",
- "tar": "^7.0.1"
+ "adm-zip": "^0.6.0",
+ "global-agent": "^4.1.3",
+ "onnxruntime-common": "1.31.1"
}
}
},
},
"node_modules/onnxruntime-web": {
"node_modules/onnxruntime-web": {
@@ -2666,12 +2756,12 @@
@@ -2666,12 +2756,12 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/serialize-error": {
"node_modules/serialize-error": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
- "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
+ "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "type-fest": "^0.13.1"
+ "type-fest": "^0.20.2"
},
},
"engines": {
"engines": {
"node": ">=10"
"node": ">=10"
@@ -2892,9 +2982,9 @@
@@ -2892,9 +2982,9 @@
"optional": true
"optional": true
},
},
"node_modules/type-fest": {
"node_modules/type-fest": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
- "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"license": "(MIT OR CC0-1.0)",
"engines": {
"engines": {
"node": ">=10"
"node": ">=10"
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
"proof": "node scripts/proof.js",
"proof": "node scripts/proof.js",
"test": "node test/run.js",
"test": "node test/run.js",
"test:pg": "VISION_TEST_STORE=pg node test/run.js",
"test:pg": "VISION_TEST_STORE=pg node test/run.js",
+ "models:faces": "node scripts/download-face-models.js",
"models:fetch": "node scripts/fetch-models.js"
"models:fetch": "node scripts/fetch-models.js"
},
},
"keywords": [
"keywords": [
@@ -26,6 +27,7 @@
@@ -26,6 +27,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.0",
+ "onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+// Pinned OpenCV Zoo assets. Download to a temporary file, verify SHA-256, then rename into DATA_DIR/models.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { modelsDir } = require('../server/paths');
+
+const MODELS = Object.freeze([
+ {
+ name: 'face_detection_yunet_2023mar.onnx',
+ url: 'https://media.githubusercontent.com/media/opencv/opencv_zoo/f12e12798e8314f7c074a6656816c048dcc95b7a/models/face_detection_yunet/face_detection_yunet_2023mar.onnx',
+ sha256: '8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4',
+ },
+ {
+ name: 'face_recognition_sface_2021dec.onnx',
+ url: 'https://media.githubusercontent.com/media/opencv/opencv_zoo/ba91a3b91d00d76e86540d4013f944bd6b514e39/models/face_recognition_sface/face_recognition_sface_2021dec.onnx',
+ sha256: '0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79',
+ },
+]);
+async function digest(file) {
+ const h = crypto.createHash('sha256');
+ for await (const chunk of fs.createReadStream(file)) h.update(chunk);
+ return h.digest('hex');
+}
+async function download(model, dir = modelsDir()) {
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ const target = path.join(dir, model.name);
+ if (fs.existsSync(target) && await digest(target) === model.sha256) return target;
+ const temp = path.join(dir, `.${model.name}.${process.pid}.tmp`);
+ try {
+ const response = await fetch(model.url, { signal: AbortSignal.timeout(120_000) });
+ if (!response.ok) throw new Error(`${model.name}: download failed (${response.status})`);
+ const file = fs.createWriteStream(temp, { mode: 0o600 });
+ for await (const chunk of response.body) {
+ if (!file.write(chunk)) await new Promise((resolve) => file.once('drain', resolve));
+ }
+ await new Promise((resolve, reject) => file.end((err) => err ? reject(err) : resolve()));
+ if (await digest(temp) !== model.sha256) throw new Error(`${model.name}: sha256 mismatch`);
+ fs.renameSync(temp, target);
+ return target;
+ } finally { fs.rmSync(temp, { force: true }); }
+}
+if (require.main === module) Promise.all(MODELS.map((m) => download(m))).then((paths) => {
+ for (const file of paths) process.stdout.write(`verified ${file}\n`);
+}).catch((err) => { console.error(err.message); process.exitCode = 1; });
+module.exports = { MODELS, download, digest };
@@ -4,14 +4,15 @@
@@ -4,14 +4,15 @@
* 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, /developers the pages (http/pages.js)
- * /api/v1/* the API (http/api.js)
+ * /, /ask, /ocr, /compare, /ui-check, /a/:id, /ui/:id, /people, /people/privacy, /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)
* /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)
* /internal/events account export/deletion (loopback only)
* /internal/events account export/deletion (loopback only)
*
*
- * Every model call goes through OpenVibe.AI; the local OCR engine and the UI check run on this host. No provider
- * key, no paid provider and no image bytes are stored.
+ * Every model call goes through OpenVibe.AI; the local OCR engine, the UI check and the local face models run on this
+ * host. No provider key, no paid provider, and no general answer stores image bytes. Only the opt-in face library
+ * keeps encrypted uploads.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -27,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -27,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const { createFaceApi } = require('./http/faces-api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -37,6 +39,8 @@ const { createStore } = require('./store');
@@ -37,6 +39,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 { createFacesModel } = require('./vision/faces-model');
+const { createFaces } = require('./vision/faces');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -62,6 +66,7 @@ async function createApp(opts = {}) {
@@ -62,6 +66,7 @@ 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 faces = createFaces({ s, config, model: opts.facesModel || createFacesModel({ dir: config.faces.modelsDir }) });
const models = require('./vision/local-models').createLocalModels({ config, clip: opts.clip, detector: opts.detector });
const models = require('./vision/local-models').createLocalModels({ config, clip: opts.clip, detector: opts.detector });
const networkTools = require('./vision/network-tools').createNetworkTools({ s, ai, ocr, models, config, fetchImpl });
const networkTools = require('./vision/network-tools').createNetworkTools({ s, ai, ocr, models, config, fetchImpl });
@@ -72,7 +77,7 @@ async function createApp(opts = {}) {
@@ -72,7 +77,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Vision cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Vision cannot answer account events'); });
- const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, models, networkTools, accountData, accountSend, log, fetchImpl };
+ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, models, networkTools, accountData, accountSend, log, fetchImpl };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -115,7 +120,7 @@ async function createApp(opts = {}) {
@@ -115,7 +120,7 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-vision', name: 'OpenVibe.Vision', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-vision', name: 'OpenVibe.Vision', version: VERSION }));
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- const readiness = createServiceReadiness({ s, config, valkey, ai, ocr, models });
+ const readiness = createServiceReadiness({ s, config, valkey, ai, ocr, faces, models });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
@@ -149,6 +154,7 @@ async function createApp(opts = {}) {
@@ -149,6 +154,7 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.VISION_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.VISION_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ app.use('/api/v1/faces', createFaceApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
*/
*/
require('dotenv').config();
require('dotenv').config();
const trim = (s) => String(s || '').replace(/\/+$/, '');
const trim = (s) => String(s || '').replace(/\/+$/, '');
+const { modelsDir } = require('./paths');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
function load(env = process.env) {
function load(env = process.env) {
@@ -96,6 +97,16 @@ function load(env = process.env) {
@@ -96,6 +97,16 @@ function load(env = process.env) {
timeoutMs: Math.max(1000, int(env.VISION_IMAGE_TIMEOUT_MS, 10000)),
timeoutMs: Math.max(1000, int(env.VISION_IMAGE_TIMEOUT_MS, 10000)),
allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',
allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',
},
},
+ faces: {
+ modelsDir: modelsDir(env),
+ key: env.VISION_FACE_KEY || '',
+ allowedRegions: String(env.VISION_FACE_LIBRARY_REGIONS || '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),
+ threshold: (() => {
+ const raw = String(env.VISION_FACE_MATCH_THRESHOLD || '').trim();
+ const n = raw ? Number(raw) : 0.363;
+ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0.363;
+ })(),
+ },
// The UI check: a static layout pass over HTML or a fetched page (no JavaScript executed), plus a
// The UI check: a static layout pass over HTML or a fetched page (no JavaScript executed), plus a
// screenshot path through the local OCR boxes and sharp pixel sampling. Nothing leaves this host.
// screenshot path through the local OCR boxes and sharp pixel sampling. Nothing leaves this host.
@@ -2,8 +2,9 @@
@@ -2,8 +2,9 @@
/**
/**
* OpenVibe.Vision's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
* OpenVibe.Vision's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
- * Nothing written here is ever a secret: an answer stores the cleaned text, a sha256 of the image and the model
- * provenance, never the image bytes, a token or a provider key.
+ * A general answer stores the cleaned text, a sha256 of the image and the model provenance, never the image bytes,
+ * a token or a provider key. The opt-in face tables hold encrypted templates/uploads and consent records, never the
+ * AES key.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -13,7 +13,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -13,7 +13,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Vision';
const SITE_NAME = 'OpenVibe.Vision';
-const DESCRIPTION = 'OpenVibe.Vision sees with the best model for the job: ask a picture, read its text with a local OCR engine first, compare models side by side through OpenVibe.AI, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.';
+const DESCRIPTION = 'OpenVibe.Vision reads images and checks pages. Local face detection and blur are open; identifying matches require separate, purpose-scoped consent.';
const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/a/', '/ui/'];
const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/a/', '/ui/'];
function dayOf(ts) {
function dayOf(ts) {
@@ -40,6 +40,9 @@ function publicPages() {
@@ -40,6 +40,9 @@ function publicPages() {
{ path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },
{ path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },
{ path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },
{ path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },
{ path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },
{ path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },
+ { path: '/people', changefreq: 'weekly', priority: 0.6, title: 'People and consent', text: 'Separate choices for self verification, self search, cast tagging and private library grouping.' },
+ { path: '/privacy', changefreq: 'yearly', priority: 0.3, title: 'Privacy policy', text: 'The privacy policy for OpenVibe.Vision.' },
+ { path: '/people/privacy', changefreq: 'weekly', priority: 0.6, title: 'Face privacy', text: 'Local processing, private links, retention, erasure and prohibited uses.' },
{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Vision API', text: 'The JSON API, the capabilities other services need, and what leaves this host.' },
{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Vision API', text: 'The JSON API, the capabilities other services need, and what leaves this host.' },
];
];
}
}
@@ -61,8 +64,8 @@ function createDiscoveryRoutes(ctx) {
@@ -61,8 +64,8 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
- summary: 'OpenVibe.Vision answers questions about a picture. The cheapest engine first: local Tesseract OCR answers a read question; otherwise OpenVibe.AI\'s routed vision model answers. A compare runs the local reader and AI models on the same image and reports each answer with its model, cost and latency. The UI check flags overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot. No face recognition, ever; public AI text never describes people\'s looks.',
- details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare or vision.ui.check.',
+ summary: 'OpenVibe.Vision answers questions about a picture, reads text locally and checks UI faults. Local face detection and blur need no identity; self verification, self search, cast tagging and library grouping each require separate consent. Public AI text never describes people\'s looks.',
+ details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. Face detection/blur are open at POST /api/v1/faces/detect and /blur; matching needs separate signed consent per purpose (verify, find, cast, library). A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare, vision.ui.check or vision.face.cast.match.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Vision', url: abs('/') },
{ title: 'OpenVibe.Vision', url: abs('/') },
@@ -70,6 +73,8 @@ function createDiscoveryRoutes(ctx) {
@@ -70,6 +73,8 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Read text from a picture', url: abs('/ocr') },
{ title: 'Read text from a picture', url: abs('/ocr') },
{ title: 'Compare models', url: abs('/compare') },
{ title: 'Compare models', url: abs('/compare') },
{ title: 'The UI check', url: abs('/ui-check') },
{ title: 'The UI check', url: abs('/ui-check') },
+ { title: 'People and consent', url: abs('/people') },
+ { title: 'Face privacy', url: abs('/people/privacy') },
{ title: 'The API', url: abs('/developers') },
{ title: 'The API', url: abs('/developers') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
@@ -91,14 +96,14 @@ function createDiscoveryRoutes(ctx) {
@@ -91,14 +96,14 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms-full.txt', (_req, res) => {
r.get('/llms-full.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
site: SITE_NAME,
- summary: 'Every public page of OpenVibe.Vision: the home, ask, OCR, compare, the UI check and the API.',
+ summary: 'Every public page of OpenVibe.Vision: the home, ask, OCR, compare, the UI check, people and consent, face privacy and the API.',
base: site,
base: site,
maxBytes: 256 * 1024,
maxBytes: 256 * 1024,
sections: [
sections: [
{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
{ title: 'Rules', pages: [
{ title: 'Rules', pages: [
- { url: '/developers', title: 'No face recognition', text: 'Vision never identifies or recognises faces and never describes a person\'s looks in public AI text.' },
- { url: '/developers', title: 'No stored images', text: 'Answers store a sha256, the media type and the cleaned text; never the image bytes.' },
+ { url: '/people/privacy', title: 'Faces with consent', text: 'Face matching is local and limited to a separately consented purpose. Public AI text never describes a person\'s looks.' },
+ { url: '/developers', title: 'No stored general images', text: 'General answers store a sha256, the media type and the cleaned text, never the image bytes. Only an opt-in face library keeps encrypted uploads.' },
] },
] },
],
],
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+const express = require('express');
+const multer = require('multer');
+const rateLimit = require('express-rate-limit');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { loadImage } = require('../vision/images');
+const { VisionError } = require('../vision/service');
+
+function createFaceApi({ config, principal, faces, fetchImpl }) {
+ const r = asyncRouter();
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
+ const imageField = (req, res, next) => upload.single('image')(req, res, next);
+ r.use(express.json({ limit: `${Math.ceil(config.images.maxMb * 1.5) + 2}mb` }));
+ r.use((_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ r.use(principal.middleware);
+ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
+ const person = (req, res, next) => {
+ if (req.principal.kind !== 'user') return problem(res, 401, 'faces.person_required', 'Sign in with your own Network account.');
+ if (req.method !== 'GET' && req.principal.viaSession && !sameOrigin(req, config.baseUrl)) return problem(res, 403, 'vision.cross_site', 'This signed-in change must come from Vision.');
+ return next();
+ };
+ async function image(req) {
+ const b = req.body || {};
+ return loadImage({ file: req.file && req.file.buffer, image: b.image, imageUrl: b.image_url, config, fetchImpl });
+ }
+ const run = (fn) => async (req, res) => {
+ try { return res.json(await fn(req, res)); }
+ catch (err) {
+ if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail);
+ if (err && /^image\./.test(err.code || '')) return problem(res, err.code === 'image.too_large' ? 413 : 400, `vision.${err.code.replace('.', '_')}`, err.message);
+ throw err;
+ }
+ };
+ const privacyLimit = rateLimit({
+ windowMs: 60_000, limit: Number(process.env.VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN) || 30,
+ standardHeaders: true, legacyHeaders: false,
+ handler: (req, res) => problem(res, 429, 'vision.rate_limited', 'too many face privacy requests from this address; retry shortly'),
+ });
+ r.post('/detect', privacyLimit, imageField, run(async (req) => faces.detect((await image(req)).buffer)));
+ r.post('/blur', privacyLimit, imageField, async (req, res) => {
+ try {
+ const out = await faces.blur((await image(req)).buffer);
+ res.set('X-Faces-Blurred', String(out.count));
+ return res.type('image/png').send(out.buffer);
+ } catch (err) {
+ if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail);
+ if (err && /^image\./.test(err.code || '')) return problem(res, err.code === 'image.too_large' ? 413 : 400, `vision.${err.code.replace('.', '_')}`, err.message);
+ throw err;
+ }
+ });
+ r.get('/consents/text', (_req, res) => res.json(faces.TEXT));
+ r.get('/consents', person, run((req) => faces.listConsents(req.principal.subject)));
+ r.post('/consents/:feature', person, run((req) => faces.consent(req.principal.subject, req.params.feature, {
+ signedName: req.body?.signed_name, textVersion: req.body?.text_version, channel: req.body?.channel,
+ adult: req.body?.adult, region: req.body?.region,
+ })));
+ r.delete('/consents/:feature', person, run((req) => faces.revoke(req.principal.subject, req.params.feature, req.body?.channel || req.query.channel)));
+ r.post('/enrol/:feature', person, imageField, run(async (req) => faces.enrol(req.principal.subject, req.params.feature, (await image(req)).buffer, req.body?.channel)));
+ r.post('/verify', person, imageField, run(async (req) => faces.verify(req.principal.subject, (await image(req)).buffer)));
+ r.post('/media', person, imageField, run(async (req) => { const im = await image(req); return faces.upload(req.principal.subject, im.buffer, im.mime); }));
+ r.get('/media/:id', person, async (req, res) => {
+ try { const im = await faces.media(req.principal.subject, req.params.id); return res.type(im.mime).send(im.buffer); }
+ catch (err) { if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail); throw err; }
+ });
+ r.post('/find', person, run((req) => faces.find(req.principal.subject)));
+ r.get('/cast/tags', person, run((req) => faces.tags(req.principal.subject)));
+ r.post('/cast/match', imageField, async (req, res, next) => {
+ const p = req.principal, channel = req.body?.channel;
+ if (p.kind !== 'service' || p.sub !== 'svc:live' || !principal.granted(p, 'vision.face.cast.match') || p.claims.channel_id !== channel) {
+ return problem(res, 403, 'faces.live_grant_required', 'A Live token scoped to this channel is required.');
+ }
+ return next();
+ }, run(async (req) => faces.castMatch(req.body.channel, (await image(req)).buffer, req.body.media_ref)));
+ r.post('/library/cluster', person, run((req) => faces.cluster(req.principal.subject, req.body?.region)));
+ r.get('/library/groups', person, run((req) => faces.groups(req.principal.subject)));
+ r.post('/library/correct', person, run((req) => faces.correction(req.principal.subject, req.body?.action, req.body?.ids)));
+ // multer errors are translated without exposing file contents.
+ r.use((err, req, res, next) => err && err.code === 'LIMIT_FILE_SIZE'
+ ? problem(res, 413, 'vision.image_too_large', 'Image exceeds the configured size limit.') : next(err));
+ return r;
+}
+module.exports = { createFaceApi };
@@ -12,6 +12,9 @@
@@ -12,6 +12,9 @@
* /ui-check HTML, URL or screenshot (POST /ui-check → /ui/:id, or inline for a guest)
* /ui-check HTML, URL or screenshot (POST /ui-check → /ui/:id, or inline for a guest)
* /a/:id one saved answer (owner or staff)
* /a/:id one saved answer (owner or staff)
* /ui/:id one saved UI check (owner or staff)
* /ui/:id one saved UI check (owner or staff)
+ * /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
* /developers the API
* /developers the API
* 404
* 404
*/
*/
@@ -28,11 +31,12 @@ const { html, raw } = require('../render/html');
@@ -28,11 +31,12 @@ const { html, raw } = 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 PUBLIC_CACHE = 'public, max-age=60';
const PUBLIC_CACHE = 'public, max-age=60';
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, s, store, vision, ai, ocr, fetchImpl } = ctx;
+ const { config, s, store, vision, ai, ocr, faces, fetchImpl } = 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 } });
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
r.use(express.urlencoded({ extended: false, limit: '2mb' }));
@@ -59,11 +63,35 @@ function createPageRoutes(ctx) {
@@ -59,11 +63,35 @@ function createPageRoutes(ctx) {
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
r.get('/', (req, res) => page(req, res, {
r.get('/', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE, title: null,
index: true, cache: PUBLIC_CACHE, title: null,
- description: 'Ask a picture, compare the local reader with an AI model, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.',
+ description: 'Ask a picture, read text, check a page, find your images and use local face privacy tools with consent for personal matching.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
}));
}));
+ r.get('/people', async (req, res) => page(req, res, { title: 'People and consent', body: raw(peopleViews.people({
+ signedIn: Boolean(signedIn(req)), consents: signedIn(req) ? await faces.listConsents(req.viewer.subject) : [],
+ texts: faces.TEXT, regions: config.faces.allowedRegions,
+ })) }));
+ r.get('/people/privacy', (req, res) => page(req, res, { title: 'Face privacy', body: raw(peopleViews.privacy()) }));
+ r.post('/people/consent/:feature', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'People', text: 'Sign in to choose.', next: '/people' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ try {
+ if (req.body.enabled === 'yes') await faces.consent(req.viewer.subject, req.params.feature, {
+ signedName: req.body.signed_name, textVersion: req.body.text_version, channel: req.body.channel,
+ adult: req.body.adult === 'true', region: req.body.region,
+ });
+ else await faces.revoke(req.viewer.subject, req.params.feature, req.body.channel);
+ return res.redirect(303, '/people');
+ } catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || 'That choice could not be saved.')) }, err.status || 400); }
+ });
+ r.post('/people/enrol/:feature', uploadMiddleware, async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: raw(views.signIn({ title: 'People', text: 'Sign in to enrol.', next: '/people' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ try { await faces.enrol(req.viewer.subject, req.params.feature, (await imageOf(req)).buffer, req.body.channel); return res.redirect(303, '/people'); }
+ catch (err) { return page(req, res, { title: 'People', body: raw(views.formError(err.detail || err.message || 'Enrolment failed.')) }, err.status || 400); }
+ });
+
const toolPage = (req, res, title, intro, fields) => page(req, res, {
const toolPage = (req, res, title, intro, fields) => page(req, res, {
title, cache: PUBLIC_CACHE,
title, cache: PUBLIC_CACHE,
body: html`<main class="vi-wrap vi-page-pad vi-narrow"><header class="vi-section-head"><h1>${title}</h1><p class="vi-lede">${intro}</p></header><div class="vi-panel"><p class="vi-fine">These tools are available through Vision's JSON API. Sign in and send an image as multipart <code>image</code> or a data URL.</p>${fields}</div></main>`,
body: html`<main class="vi-wrap vi-page-pad vi-narrow"><header class="vi-section-head"><h1>${title}</h1><p class="vi-lede">${intro}</p></header><div class="vi-panel"><p class="vi-fine">These tools are available through Vision's JSON API. Sign in and send an image as multipart <code>image</code> or a data URL.</p>${fields}</div></main>`,
@@ -7,9 +7,10 @@
@@ -7,9 +7,10 @@
* a person: their Network user token as a Bearer, or this site's session cookie (vision_at).
* a person: their Network user token as a Bearer, or this site's session cookie (vision_at).
* { kind: 'service', sub: 'svc:design', cap: [...], project, claims }
* { kind: 'service', sub: 'svc:design', cap: [...], project, claims }
* a Network service, app or agent token for audience openvibe.vision. Each machine route names exactly one
* a Network service, app or agent token for audience openvibe.vision. Each machine route names exactly one
- * capability (including the six network tools) and checks the
- * token's grant list with openvibe-contracts capabilities.grants. Unknown capability ids are accepted here
- * until the main session registers the manifests; the main session adds the grants.
+ * capability (vision.ui.check for Design's Arena, vision.face.cast.match for Live's channel roster, vision.ask,
+ * vision.ocr, vision.compare, and the six network tools) and checks the token's grant list with
+ * openvibe-contracts capabilities.grants. Unknown capability ids are accepted here until the main session
+ * registers the manifests; the main session adds the grants.
* { kind: 'anonymous' }
* { kind: 'anonymous' }
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
@@ -22,7 +23,10 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
@@ -22,7 +23,10 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
/** Vision's machine capabilities (the main session registers them; Vision accepts the grant directly). */
/** Vision's machine capabilities (the main session registers them; Vision accepts the grant directly). */
-const CAPABILITIES = Object.freeze(['vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check', 'vision.alt', 'vision.find', 'vision.receipt', 'vision.product', 'vision.watch', 'vision.moments']);
+const CAPABILITIES = Object.freeze([
+ 'vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check', 'vision.face.cast.match',
+ 'vision.alt', 'vision.find', 'vision.receipt', 'vision.product', 'vision.watch', 'vision.moments',
+]);
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -2,15 +2,20 @@
@@ -2,15 +2,20 @@
/**
/**
* Account export and deletion → Vision (ADR-033; openvibe-sdk/account-data). The rows a person owns are their
* Account export and deletion → Vision (ADR-033; openvibe-sdk/account-data). The rows a person owns are their
- * answers, their compare samples and their UI checks. The local OCR text is part of the answer; image bytes are
- * never stored, so there is nothing else to erase. A row written by a service token has no subject and is not a
- * person's account data.
+ * answers, compare samples, UI checks, face consents, encrypted templates/uploads, cast tags and library groups.
+ * A general answer keeps no image bytes; the opt-in face rows export their ciphertext, never the server key. A row
+ * written by a service token has no subject and is not a person's account data.
*
*
* The database stores bare usr_ ids in the subject columns; an exported file never includes another person's row.
* The database stores bare usr_ ids in the subject columns; an exported file never includes another person's row.
*/
*/
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
+ { table: 'face_tags', subject: 'subject', value: (usr) => usr, file: 'face-tags.json', erase: 'delete' },
+ { table: 'face_clusters', subject: 'subject', value: (usr) => usr, file: 'face-clusters.json', erase: 'delete' },
+ { table: 'face_media', subject: 'subject', value: (usr) => usr, file: 'face-media.json', erase: 'delete' },
+ { table: 'face_templates', subject: 'subject', value: (usr) => usr, file: 'face-templates.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' },
// 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' },
@@ -23,7 +28,7 @@ function create({ db, log = console } = {}) {
@@ -23,7 +28,7 @@ function create({ db, log = console } = {}) {
db,
db,
service: 'vision',
service: 'vision',
tables: TABLES,
tables: TABLES,
- note: 'Image bytes are never stored: an answer holds its cleaned text, a sha256 of the image and model provenance.',
+ note: 'General answers keep no image bytes: an answer holds its cleaned text, a sha256 of the image and model provenance. Face templates and opt-in uploads are exported as AES-GCM ciphertext, never the server key.',
log,
log,
});
});
}
}
@@ -20,6 +20,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
@@ -20,6 +20,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => { for (const t of timers) clearInterval(t); },
() => { for (const t of timers) clearInterval(t); },
() => ctx.keys.client.stop(),
() => ctx.keys.client.stop(),
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
+ () => clearInterval(ctx.faceRetentionTick),
() => ctx.s.close(),
() => ctx.s.close(),
...extra,
...extra,
],
],
@@ -40,6 +41,12 @@ async function start() {
@@ -40,6 +41,12 @@ async function start() {
ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick.unref();
ctx.telemetryTick.unref();
+ // At boot and daily, remove expired face data. Drills never run this job.
+ if (!config.drill) {
+ ctx.faceRetentionTick = setInterval(() => { ctx.faces.retention().catch((err) => console.error('[OpenVibe.Vision] face retention:', err.message)); }, 24 * 60 * 60_000);
+ ctx.faceRetentionTick.unref();
+ ctx.faces.retention().catch((err) => console.error('[OpenVibe.Vision] face retention:', err.message));
+ }
ctx.keys.client.start();
ctx.keys.client.start();
// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
const { jwksStatus } = require('openvibe-sdk/auth');
const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
-function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null, models = null }) {
+function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null, faces = null, models = null }) {
return createReadiness({
return createReadiness({
service: 'vision',
service: 'vision',
release,
release,
@@ -43,6 +43,12 @@ function createServiceReadiness({ s, config, valkey = null, release = null, ai =
@@ -43,6 +43,12 @@ function createServiceReadiness({ s, config, valkey = null, release = null, ai =
return v ? { ok: true, detail: v } : 'tesseract is not installed: OCR and the screenshot pass answer 503';
return v ? { ok: true, detail: v } : 'tesseract is not installed: OCR and the screenshot pass answer 503';
},
},
},
},
+ { name: 'face_models', required: false, check: () => {
+ const state = faces && faces.available();
+ return state && state.yunet && state.sface && state.key
+ ? { ok: true, detail: { mode: 'local ONNX only', detect: Boolean(state.yunet), match: Boolean(state.sface && state.key) } }
+ : 'Face matching is unavailable: install YuNet/SFace weights and set VISION_FACE_KEY; detect/blur need only YuNet and other Vision routes still serve';
+ } },
],
],
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+'use strict';
+const path = require('path');
+
+// One host-owned data root; model weights are never part of the repository.
+function dataDir(env = process.env) {
+ return path.resolve(env.DATA_DIR || path.join(__dirname, '..', 'data'));
+}
+function modelsDir(env = process.env) { return path.join(dataDir(env), 'models'); }
+module.exports = { dataDir, modelsDir };
@@ -27,6 +27,8 @@ const NAV = [
@@ -27,6 +27,8 @@ const NAV = [
{ label: 'Watch', href: '/watch' },
{ label: 'Watch', href: '/watch' },
{ label: 'Compare', href: '/compare' },
{ label: 'Compare', href: '/compare' },
{ label: 'UI check', href: '/ui-check' },
{ label: 'UI check', href: '/ui-check' },
+ { label: 'People', href: '/people' },
+ { label: 'Privacy', href: '/people/privacy' },
{ label: 'Developers', href: '/developers' },
{ label: 'Developers', href: '/developers' },
];
];
@@ -30,7 +30,7 @@ ${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your acc
@@ -30,7 +30,7 @@ ${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your acc
room: 'vision',
room: 'vision',
hero: {
hero: {
title: 'See with the best model for the job',
title: 'See with the best model for the job',
- lede: 'Ask a picture. Read its text with a local OCR engine first. Compare the local reader with a routed vision model on the same image, with the cost and the time beside each answer. Check a page for the overflow, low contrast and layering faults a person would catch. No face recognition, ever.',
+ lede: 'Ask a picture. Read its text with a local OCR engine first. Compare the local reader with a routed vision model on the same image, with the cost and the time beside each answer. Check a page for overflow and layering faults. Local face tools use separate consent for each identifying purpose.',
start: { href: '/ask', label: 'Ask a picture' },
start: { href: '/ask', label: 'Ask a picture' },
chips: [{ label: 'Read text', href: '/ocr' }, { label: 'Compare models', href: '/compare' }, { label: 'UI check', href: '/ui-check' }, { label: 'The API', href: '/developers' }],
chips: [{ label: 'Read text', href: '/ocr' }, { label: 'Compare models', href: '/compare' }, { label: 'UI check', href: '/ui-check' }, { label: 'The API', href: '/developers' }],
proof: [{ value: '0.0000', label: 'USD for the local OCR first pass' }],
proof: [{ value: '0.0000', label: 'USD for the local OCR first pass' }],
@@ -57,9 +57,9 @@ ${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your acc
@@ -57,9 +57,9 @@ ${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your acc
},
},
],
],
faq: { items: [
faq: { items: [
- ['Does Vision recognise faces?', 'No. Never. Vision identifies what an image shows without identifying people, and public AI text never describes a person\'s looks. The prompt and the stored answer both enforce the no-looks rule.'],
+ ['Does Vision recognise faces?', 'Only for a person who separately opts into a specific purpose: self verification, self search, cast tagging or private library grouping. Detect and blur never identify anyone. Public AI text never describes a person\'s looks.'],
['Which model answers?', 'Every answer names the model and provider that actually answered and the reason it was chosen. Local OCR is first for a read question; otherwise OpenVibe.AI routes the run and its provenance is what Vision reports.'],
['Which model answers?', 'Every answer names the model and provider that actually answered and the reason it was chosen. Local OCR is first for a read question; otherwise OpenVibe.AI routes the run and its provenance is what Vision reports.'],
- ['Are my images stored?', 'No image bytes are stored. An answer keeps a sha256, the media type, the cleaned text and the model provenance. A signed-in person\'s rows are exported and erased with their account.'],
+ ['Are my images stored?', 'General answers keep no image bytes. An opt-in face library stores encrypted uploads and vectors privately; turning its purposes off erases derived data. Account export and deletion include those rows.'],
] },
] },
});
});
const at = room.indexOf('<section class="sc-room-scenes"');
const at = room.indexOf('<section class="sc-room-scenes"');
@@ -85,7 +85,7 @@ function ask({ signedIn = false, ai = false, problem = null, values = {} } = {})
@@ -85,7 +85,7 @@ function ask({ signedIn = false, ai = false, problem = null, values = {} } = {})
const v = { task: 'ask', mode: 'auto', ...values };
const v = { task: 'ask', mode: 'auto', ...values };
return `<div class="vi-wrap" id="vi-ask">
return `<div class="vi-wrap" id="vi-ask">
<header class="vi-page-h"><p class="vi-kicker">Ask a picture</p><h1>What does the image show?</h1>
<header class="vi-page-h"><p class="vi-kicker">Ask a picture</p><h1>What does the image show?</h1>
-<p class="vi-lede">Describe, read, translate, identify or count. A read question goes to the local OCR reader first; the answer says which engine answered and why. No face recognition, ever.</p></header>
+<p class="vi-lede">Describe, read, translate, identify or count. A read question goes to the local OCR reader first; the answer says which engine answered and why. Face matching has separate consent controls on <a href="/people">People</a>.</p></header>
${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
${signedIn ? '' : `<p class="vi-note">Ask saves the answer to an account. <a href="/auth/login?next=%2Fask">Sign in with OpenVibe</a> to ask; <a href="/ocr">OCR</a> and the <a href="/ui-check">UI check</a> work without one.</p>`}
${signedIn ? '' : `<p class="vi-note">Ask saves the answer to an account. <a href="/auth/login?next=%2Fask">Sign in with OpenVibe</a> to ask; <a href="/ocr">OCR</a> and the <a href="/ui-check">UI check</a> work without one.</p>`}
${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured on this host. A read question still runs on the local OCR engine.</p>'}
${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured on this host. A read question still runs on the local OCR engine.</p>'}
@@ -241,8 +241,23 @@ ${ep('GET', '/api/v1/ui-checks/:id', 'One saved UI check, for its owner or a ser
@@ -241,8 +241,23 @@ ${ep('GET', '/api/v1/ui-checks/:id', 'One saved UI check, for its owner or a ser
${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}
${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}
${ep('GET', '/api/v1/benchmarks?task=&limit=', 'Aggregate compare samples: task, engine, provider, model, samples, mean latency and mean cost. No subject data.')}
${ep('GET', '/api/v1/benchmarks?task=&limit=', 'Aggregate compare samples: task, engine, provider, model, samples, mean latency and mean cost. No subject data.')}
</dl></section>
</dl></section>
+<section id="vi-faces"><h2>Faces, by consent</h2><dl class="vi-eps">
+${ep('POST', '/api/v1/faces/detect', 'Anyone. YuNet boxes and a count; no template, identity or cross-frame tracking.')}
+${ep('POST', '/api/v1/faces/blur', 'Anyone. The same YuNet boxes drive an image/png redaction. Both open routes are rate-limited.')}
+${ep('GET', '/api/v1/faces/consents/text', 'The four signed texts and their versions. A person signs one purpose with <code>signed_name</code> and <code>text_version</code> at <code>POST /api/v1/faces/consents/:feature</code>; <code>DELETE</code> revokes and erases.')}
+${ep('POST', '/api/v1/faces/enrol/:feature', 'A person, after that purpose\'s consent: exactly one face into an AES-GCM encrypted SFace template.')}
+${ep('POST', '/api/v1/faces/verify', 'A person. Returns <code>{ score, match, threshold, liveness: "not_checked" }</code> against their own template, never an identity. A match alone unlocks nothing.')}
+${ep('POST', '/api/v1/faces/media', 'A person with find or library consent. Stores an encrypted Vision upload; <code>GET /api/v1/faces/media/:id</code> is owner-only.')}
+${ep('POST', '/api/v1/faces/find', 'A person with self-search consent. Matches only their own template in their own Vision uploads and returns private links.')}
+${ep('POST', '/api/v1/faces/cast/match', 'Live with <code>vision.face.cast.match</code> and a signed <code>channel_id</code> claim. Reads only that channel\'s adult, consenting roster.')}
+${ep('GET', '/api/v1/faces/cast/tags', 'A person\'s own cast tags; revoking a grant deletes them.')}
+${ep('POST', '/api/v1/faces/library/cluster', 'A person with library consent in an allowed region. Private clusters; unknown faces stay unlabelled.')}
+${ep('GET', '/api/v1/faces/library/groups', 'A person\'s own groups. No subject or public index.')}
+${ep('POST', '/api/v1/faces/library/correct', 'A person\'s own group correction: <code>exclude</code>, <code>merge</code> or <code>split</code>.')}
+</dl>
+<p class="vi-fine"><a href="/people">Manage choices</a> · <a href="/people/privacy">Retention and privacy</a></p></section>
<section id="vi-capabilities"><h2>Capabilities and grants</h2>
<section id="vi-capabilities"><h2>Capabilities and grants</h2>
-<p class="vi-fine">Vision needs no new AI capability: outgoing calls are <code>ai.run.create</code> to OpenVibe.AI, audience <code>openvibe.ai</code>, namespace <code>ai.*</code>. The capabilities Vision offers are <code>vision.ask</code>, <code>vision.ocr</code>, <code>vision.compare</code> and <code>vision.ui.check</code>; the main session registers the manifests and grants them to Design (the Arena) and any other caller.</p>
+<p class="vi-fine">Vision needs no new AI capability: outgoing calls are <code>ai.run.create</code> to OpenVibe.AI, audience <code>openvibe.ai</code>, namespace <code>ai.*</code>. The capabilities Vision offers are <code>vision.ask</code>, <code>vision.ocr</code>, <code>vision.compare</code>, <code>vision.ui.check</code> and <code>vision.face.cast.match</code>; the main session registers the manifests and grants them to Design (the Arena), Live (the cast roster) and any other caller.</p>
<pre class="vi-code">POST /api/v1/ui-check
<pre class="vi-code">POST /api/v1/ui-check
Authorization: Bearer <Network service token for openvibe.vision>
Authorization: Bearer <Network service token for openvibe.vision>
Content-Type: application/json
Content-Type: application/json
@@ -250,7 +265,7 @@ Content-Type: application/json
@@ -250,7 +265,7 @@ Content-Type: application/json
{ "html": "<!doctype html>…", "viewport": { "w": 390, "h": 844 } }</pre>
{ "html": "<!doctype html>…", "viewport": { "w": 390, "h": 844 } }</pre>
</section>
</section>
<section id="vi-limits"><h2>What leaves this host</h2>
<section id="vi-limits"><h2>What leaves this host</h2>
-<p class="vi-fine">The local OCR engine and the UI check never leave the host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; AI's own routing, quotas and provenance apply. Vision holds no provider key. No image bytes are stored, and no face recognition is ever used. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p>
+<p class="vi-fine">The local OCR engine, UI check and all face tools run on this host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; face images and templates never go there. General answers store no image bytes; opt-in face uploads are encrypted and private. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p>
<p class="vi-fine">AI on this host: ${ai ? 'configured' : 'not configured (local engines only)'}. Model hints: ${esc((models || []).join(', ') || 'from AI\'s routes at request time')}.</p>
<p class="vi-fine">AI on this host: ${ai ? 'configured' : 'not configured (local engines only)'}. Model hints: ${esc((models || []).join(', ') || 'from AI\'s routes at request time')}.</p>
</section>
</section>
<section id="vi-search"><h2>Search</h2><p class="vi-fine">v1 has no public objects: answers and UI checks belong to the caller and are not indexed, so Vision writes no Search document and has no outbox. The v2 Arena's public leaderboards are where public records will begin.</p></section>
<section id="vi-search"><h2>Search</h2><p class="vi-fine">v1 has no public objects: answers and UI checks belong to the caller and are not indexed, so Vision writes no Search document and has no outbox. The v2 Arena's public leaderboards are where public records will begin.</p></section>
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+const { esc } = require('../render/html');
+
+const LABELS = { verify: 'Verify yourself', find: 'Find yourself', cast: 'Cast tagging', library: "Your library's people" };
+function people({ signedIn, consents = [], texts, regions = [] }) {
+ const active = (f) => consents.find((c) => c.feature === f && !c.revoked_at);
+ return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-people"><header class="vi-page-h"><p class="vi-kicker">Your choice</p><h1>People in Vision</h1><p class="vi-lede">Each purpose has its own consent. Face matching runs locally. Turning a feature off erases its template at once.</p></header>
+${signedIn ? '' : '<p class="vi-note">Sign in with your Network account to manage face features.</p>'}
+${Object.entries(texts).map(([feature, item]) => {
+ const on = active(feature), scope = on?.scope || '', needed = on ? '' : ' required';
+ return `<section class="vi-card vi-person-feature" id="vi-${feature}"><h2>${esc(LABELS[feature])}</h2><p>${esc(item.text)}</p>
+<form class="vi-form" method="post" action="/people/consent/${feature}">
+<input type="hidden" name="text_version" value="${esc(item.version)}">
+ ${feature === 'cast' ? `<label class="vi-field"><span>Named channel ID</span><input name="channel"${needed} value="${esc(scope)}" placeholder="live:your-channel"></label><label class="vi-check"><input type="checkbox" name="adult" value="true"${needed}> I confirm I am an adult co-streamer</label>` : ''}
+ ${feature === 'library' ? `<label class="vi-field"><span>Region</span><input name="region"${needed} placeholder="US-IL" value="${esc(regions[0] || '')}"></label><p class="vi-fine">Available regions: ${esc(regions.join(', ') || 'none configured')}</p>` : ''}
+ <label class="vi-field"><span>Signature (your typed name)</span><input name="signed_name" autocomplete="name"${needed} maxlength="160"></label>
+<label class="vi-check"><input type="checkbox" role="switch" name="enabled" value="yes" ${on ? 'checked' : ''}${signedIn ? '' : ' disabled'}> ${on ? 'On' : 'Off'}</label>
+<button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Save this choice</button></form>
+${on && feature !== 'library' ? `<form method="post" action="/people/enrol/${feature}" enctype="multipart/form-data" class="vi-form"><input type="hidden" name="channel" value="${esc(scope)}"><label class="vi-field"><span>One face image for enrolment</span><input type="file" name="image" accept="image/*" required></label><button class="vi-btn vi-btn-quiet" type="submit">Enrol my face</button></form>` : ''}
+${on ? `<p class="vi-fine">Granted ${esc(String(on.granted_at))}${scope ? ` for ${esc(scope)}` : ''} · consent ${esc(on.text_version)}</p>` : ''}</section>`;
+}).join('')}
+<p class="vi-fine">A face match cannot unlock an account by itself. Liveness is not checked yet; use another factor and a non-biometric route. <a href="/people/privacy">Read the retention and privacy rules.</a></p></div>`;
+}
+function privacy() {
+ return `<div class="vi-wrap vi-page-pad vi-narrow" id="vi-privacy"><header class="vi-page-h"><p class="vi-kicker">Privacy</p><h1>Faces stay in your chosen purpose</h1></header>
+<section class="vi-card"><h2>What happens</h2><p>Detect, count and blur use local YuNet without identity templates or tracking. Verification compares only your enrolled template with your own attempt. Find yourself searches only your own Vision uploads. Cast tagging checks a named channel roster of consenting adults. Library groups are private, region gated, and unknown faces remain unlabelled.</p></section>
+<section class="vi-card"><h2>Retention and control</h2><p>Templates and derived tags are erased when you switch a purpose off. A daily job removes active templates at the latest three years after your last use. Revoked consent audit records are removed within one year after the purpose ends. Account export and deletion include face templates, consents, uploads, groups and tags.</p></section>
+<section class="vi-card"><h2>What never happens</h2><p>No face or template is sent to a cloud model, public Search, model prompts or Bench. No stranger identification against the open web. No public face index. AI text never describes a person's looks. A face match alone never unlocks anything: liveness is not checked and a second factor and non-biometric route are required for consequential use.</p></section>
+<p><a href="/privacy">The full OpenVibe.Vision privacy policy</a> · <a href="/people">Manage your choices</a></p></div>`;
+}
+module.exports = { people, privacy };
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+'use strict';
+
+// YuNet/SFace ONNX inference runs in this process. No face bytes or vectors reach OpenVibe.AI.
+const fs = require('fs');
+const path = require('path');
+const sharp = require('sharp');
+const { modelsDir } = require('../paths');
+
+const FILES = Object.freeze({
+ yunet: 'face_detection_yunet_2023mar.onnx',
+ sface: 'face_recognition_sface_2021dec.onnx',
+});
+const STRIDES = [8, 16, 32];
+const YUNET_INPUT = 640;
+const TARGET = [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]];
+
+function cosine(a, b) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
+ let dot = 0; let aa = 0; let bb = 0;
+ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; aa += a[i] * a[i]; bb += b[i] * b[i]; }
+ return aa && bb ? dot / Math.sqrt(aa * bb) : 0;
+}
+function iou(a, b) {
+ const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y);
+ const w = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x);
+ const h = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y);
+ return w * h / (a.width * a.height + b.width * b.height - w * h || 1);
+}
+function decodeYuNet(outputs, width = YUNET_INPUT, height = YUNET_INPUT, threshold = 0.7) {
+ const found = [];
+ for (const stride of STRIDES) {
+ const cls = outputs[`cls_${stride}`]?.data, obj = outputs[`obj_${stride}`]?.data;
+ const box = outputs[`bbox_${stride}`]?.data, kps = outputs[`kps_${stride}`]?.data;
+ if (!cls || !obj || !box || !kps) throw new Error('YuNet output shape is unsupported');
+ const cols = width / stride, rows = height / stride;
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
+ const i = r * cols + c;
+ const score = Math.sqrt(Math.max(0, Math.min(1, cls[i])) * Math.max(0, Math.min(1, obj[i])));
+ if (score < threshold) continue;
+ const cx = (c + box[i * 4]) * stride, cy = (r + box[i * 4 + 1]) * stride;
+ const w = Math.exp(box[i * 4 + 2]) * stride, h = Math.exp(box[i * 4 + 3]) * stride;
+ if (!Number.isFinite(w) || !Number.isFinite(h)) continue;
+ found.push({ x: cx - w / 2, y: cy - h / 2, width: w, height: h, score,
+ landmarks: Array.from({ length: 5 }, (_, j) => [(kps[i * 10 + 2 * j] + c) * stride, (kps[i * 10 + 2 * j + 1] + r) * stride]) });
+ }
+ }
+ found.sort((a, b) => b.score - a.score);
+ const keep = [];
+ for (const f of found.slice(0, 5000)) if (keep.every((k) => iou(f, k) < 0.3)) keep.push(f);
+ return keep;
+}
+function chw(rgb, width, height, bgr = false) {
+ const plane = width * height, out = new Float32Array(plane * 3);
+ for (let i = 0; i < plane; i++) {
+ out[i] = rgb[i * 3 + (bgr ? 2 : 0)];
+ out[plane + i] = rgb[i * 3 + 1];
+ out[plane * 2 + i] = rgb[i * 3 + (bgr ? 0 : 2)];
+ }
+ return out;
+}
+// Least-squares similarity transform, source landmarks → SFace's 112×112 landmark frame.
+function alignment(points) {
+ const mean = (p, axis) => p.reduce((n, x) => n + x[axis], 0) / p.length;
+ const sx = mean(points, 0), sy = mean(points, 1), tx = mean(TARGET, 0), ty = mean(TARGET, 1);
+ let a = 0, b = 0, den = 0;
+ for (let i = 0; i < 5; i++) {
+ const x = points[i][0] - sx, y = points[i][1] - sy;
+ const u = TARGET[i][0] - tx, v = TARGET[i][1] - ty;
+ a += x * u + y * v; b += x * v - y * u; den += x * x + y * y;
+ }
+ if (den < 1) throw new Error('face landmarks are degenerate');
+ return { a: a / den, b: b / den, sx, sy, tx, ty };
+}
+function alignedRgb(raw, width, height, points) {
+ const { a, b, sx, sy, tx, ty } = alignment(points), den = a * a + b * b;
+ const out = Buffer.alloc(112 * 112 * 3);
+ for (let y = 0; y < 112; y++) for (let x = 0; x < 112; x++) {
+ const u = x - tx, v = y - ty;
+ const px = (a * u + b * v) / den + sx, py = (-b * u + a * v) / den + sy;
+ const x0 = Math.floor(px), y0 = Math.floor(py), fx = px - x0, fy = py - y0;
+ for (let c = 0; c < 3; c++) {
+ const at = (xx, yy) => xx >= 0 && yy >= 0 && xx < width && yy < height ? raw[(yy * width + xx) * 3 + c] : 0;
+ out[(y * 112 + x) * 3 + c] = Math.round(at(x0, y0) * (1 - fx) * (1 - fy) + at(x0 + 1, y0) * fx * (1 - fy) + at(x0, y0 + 1) * (1 - fx) * fy + at(x0 + 1, y0 + 1) * fx * fy);
+ }
+ }
+ return out;
+}
+function createFacesModel({ dir = modelsDir(), ort = null } = {}) {
+ let detectionSession, matchingSession;
+ const file = (kind) => path.join(dir, FILES[kind]);
+ const available = () => ({ yunet: fs.existsSync(file('yunet')), sface: fs.existsSync(file('sface')) });
+ async function session(kind) {
+ const runtime = ort || require('onnxruntime-node');
+ if (!fs.existsSync(file(kind))) { const e = new Error(`${kind} weights are not installed`); e.code = 'faces.model_unavailable'; throw e; }
+ if (kind === 'yunet') return detectionSession ||= await runtime.InferenceSession.create(file(kind), { executionProviders: ['cpu'] });
+ return matchingSession ||= await runtime.InferenceSession.create(file(kind), { executionProviders: ['cpu'] });
+ }
+ async function pixels(buffer) {
+ return sharp(buffer, { limitInputPixels: 40_000_000, animated: false }).rotate().removeAlpha().toColourspace('srgb').raw().toBuffer({ resolveWithObject: true });
+ }
+ async function detect(buffer) {
+ const runtime = ort || require('onnxruntime-node');
+ const image = await pixels(buffer);
+ const { data } = await sharp(image.data, { raw: image.info }).resize(YUNET_INPUT, YUNET_INPUT, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });
+ const model = await session('yunet');
+ const output = await model.run({ [model.inputNames[0]]: new runtime.Tensor('float32', chw(data, YUNET_INPUT, YUNET_INPUT, true), [1, 3, YUNET_INPUT, YUNET_INPUT]) });
+ return decodeYuNet(output).map((f) => ({ ...f,
+ x: f.x * image.info.width / YUNET_INPUT, y: f.y * image.info.height / YUNET_INPUT,
+ width: f.width * image.info.width / YUNET_INPUT, height: f.height * image.info.height / YUNET_INPUT,
+ landmarks: f.landmarks.map(([x, y]) => [x * image.info.width / YUNET_INPUT, y * image.info.height / YUNET_INPUT]),
+ }));
+ }
+ async function embed(buffer, face) {
+ const runtime = ort || require('onnxruntime-node');
+ const image = await pixels(buffer);
+ const aligned = alignedRgb(image.data, image.info.width, image.info.height, face.landmarks);
+ const model = await session('sface');
+ const out = await model.run({ [model.inputNames[0]]: new runtime.Tensor('float32', chw(aligned, 112, 112), [1, 3, 112, 112]) });
+ const vector = Array.from(out[model.outputNames[0]].data);
+ if (!vector.length || vector.some((v) => !Number.isFinite(v))) throw new Error('SFace returned an invalid vector');
+ return vector;
+ }
+ async function blur(buffer, faces) {
+ const image = await pixels(buffer);
+ const base = sharp(image.data, { raw: image.info });
+ const layers = [];
+ for (const f of faces) {
+ const x = Math.max(0, Math.floor(f.x - f.width * 0.12)), y = Math.max(0, Math.floor(f.y - f.height * 0.12));
+ const w = Math.min(image.info.width - x, Math.ceil(f.width * 1.24)), h = Math.min(image.info.height - y, Math.ceil(f.height * 1.24));
+ if (w < 1 || h < 1) continue;
+ const crop = await sharp(image.data, { raw: image.info }).extract({ left: x, top: y, width: w, height: h }).blur(Math.max(8, Math.round(Math.min(w, h) / 8))).png().toBuffer();
+ layers.push({ input: crop, left: x, top: y });
+ }
+ return base.composite(layers).png().toBuffer();
+ }
+ return { available, detect, embed, blur, cosine };
+}
+module.exports = { createFacesModel, decodeYuNet, cosine, FILES, YUNET_INPUT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
+'use strict';
+const crypto = require('crypto');
+const { VisionError } = require('./service');
+
+const FEATURES = Object.freeze(['verify', 'find', 'cast', 'library']);
+const TEXT = Object.freeze({
+ verify: { version: 'face-verify-2026-10-28', text: 'Use my face template only to compare my own verification attempt with my Network account. A match alone never unlocks anything.' },
+ find: { version: 'face-find-2026-10-28', text: 'Search only my own Vision uploads for my face. Results are private to me; no public face index is made.' },
+ cast: { version: 'face-cast-2026-10-28', text: 'Match my face only within the named channel roster. I am an adult. Revoking this grant removes my tags.' },
+ library: { version: 'face-library-2026-10-28', text: 'Cluster faces in my own Vision library. Other people in my uploads may be processed. Unknown faces stay unlabelled; I can exclude, merge, split or erase the groups.' },
+});
+const deny = (code, detail, status = 403) => { throw new VisionError(status, `faces.${code}`, detail); };
+const scopeOf = (feature, channel) => {
+ if (feature !== 'cast') return '';
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9:_-]{1,119}$/.test(String(channel || ''))) deny('invalid_channel', 'Provide a named channel ID.', 400);
+ return channel;
+};
+function keyOf(raw) {
+ if (!raw) return null;
+ const value = String(raw).trim();
+ if (/^[0-9a-fA-F]{64}$/.test(value)) return Buffer.from(value, 'hex');
+ const decoded = Buffer.from(value, 'base64');
+ if (decoded.length === 32) return decoded;
+ // A host may inject exactly 32 raw bytes instead of base64; both are an AES-256 key.
+ const utf8 = Buffer.from(value, 'utf8');
+ return utf8.length === 32 ? utf8 : null;
+}
+function seal(key, value) {
+ const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
+ const data = Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value));
+ const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
+ return Buffer.concat([nonce, cipher.getAuthTag(), encrypted]).toString('base64');
+}
+function open(key, value, asBuffer = false) {
+ const bytes = Buffer.from(value, 'base64');
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12));
+ decipher.setAuthTag(bytes.subarray(12, 28));
+ const data = Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]);
+ return asBuffer ? data : JSON.parse(data.toString('utf8'));
+}
+function createFaces({ s, config, model }) {
+ const db = s.db;
+ const key = keyOf(config.faces.key);
+ const timestamp = () => s.iso();
+ function requireKey() { if (!key) deny('key_unavailable', 'Face encryption is not configured.', 503); }
+ function requireModel(kind) {
+ if (!model.available()[kind]) deny('model_unavailable', `${kind} weights are not installed.`, 503);
+ }
+ function regionAllowed(region) { return Boolean(region && config.faces.allowedRegions.includes(String(region).trim().toUpperCase())); }
+ async function active(subject, feature, scope = '') {
+ return db.maybe('SELECT * FROM face_consents WHERE subject=$1 AND feature=$2 AND scope=$3 AND revoked_at IS NULL', [subject, feature, scope]);
+ }
+ async function requireConsent(subject, feature, scope = '') {
+ const row = await active(subject, feature, scope);
+ if (!row) deny('consent_required', `Enable ${feature} consent first.`);
+ return row;
+ }
+ async function consent(subject, feature, { signedName, textVersion, channel, adult, region } = {}) {
+ if (!FEATURES.includes(feature)) deny('invalid_feature', 'Unknown face feature.', 400);
+ const scope = scopeOf(feature, channel), at = timestamp();
+ const existing = await active(subject, feature, scope);
+ if (existing) return { feature, scope, text_version: existing.text_version, granted_at: existing.granted_at };
+ if (textVersion !== TEXT[feature].version || !String(signedName || '').trim() || String(signedName).length > 160) deny('signature_required', 'Sign the current feature consent text.', 400);
+ if (feature === 'cast' && adult !== true) deny('adult_required', 'Cast enrolment is for adults only.', 400);
+ if (feature === 'library' && !regionAllowed(region)) deny('region_disabled', 'Library grouping is not enabled in this region.');
+ await db.exec('INSERT INTO face_consents (id, subject, feature, scope, text_version, signature, granted_at, last_used_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)',
+ [s.newId('fco'), subject, feature, scope, textVersion, String(signedName).trim(), at]);
+ return { feature, scope, text_version: textVersion, granted_at: at };
+ }
+ async function listConsents(subject) {
+ const rows = await db.many('SELECT id, feature, scope, text_version, granted_at, revoked_at FROM face_consents WHERE subject=$1 ORDER BY granted_at DESC', [subject]);
+ return rows;
+ }
+ async function revoke(subject, feature, channel) {
+ if (!FEATURES.includes(feature)) deny('invalid_feature', 'Unknown face feature.', 400);
+ const scope = scopeOf(feature, channel), at = timestamp();
+ await s.tx(async (t) => {
+ await t.exec('UPDATE face_consents SET revoked_at=$4, purpose_ends_at=$4 WHERE subject=$1 AND feature=$2 AND scope=$3 AND revoked_at IS NULL', [subject, feature, scope, at]);
+ await t.exec('DELETE FROM face_templates WHERE subject=$1 AND feature=$2 AND scope=$3', [subject, feature, scope]);
+ if (feature === 'cast') await t.exec('DELETE FROM face_tags WHERE subject=$1 AND channel=$2', [subject, scope]);
+ if (feature === 'find' && !(await active(subject, 'library'))) await t.exec('DELETE FROM face_media WHERE subject=$1', [subject]);
+ if (feature === 'library') {
+ await t.exec('DELETE FROM face_clusters WHERE subject=$1', [subject]);
+ if (!(await active(subject, 'find'))) await t.exec('DELETE FROM face_media WHERE subject=$1', [subject]);
+ }
+ });
+ return { feature, scope, revoked_at: at, erased: true };
+ }
+ async function enrol(subject, feature, buffer, channel) {
+ requireKey(); requireModel('yunet'); requireModel('sface');
+ if (!['verify', 'find', 'cast'].includes(feature)) deny('invalid_feature', 'Enrolment needs verify, find or cast consent.', 400);
+ const scope = scopeOf(feature, channel);
+ await requireConsent(subject, feature, scope);
+ const faces = await model.detect(buffer);
+ if (faces.length !== 1) deny('one_face_required', 'Enrolment needs exactly one face.', 422);
+ const vector = await model.embed(buffer, faces[0]), at = timestamp();
+ await db.exec(`INSERT INTO face_templates (subject, feature, scope, ciphertext, created_at, last_used_at) VALUES ($1,$2,$3,$4,$5,$5)
+ ON CONFLICT (subject, feature, scope) DO UPDATE SET ciphertext=EXCLUDED.ciphertext, last_used_at=EXCLUDED.last_used_at`,
+ [subject, feature, scope, seal(key, vector), at]);
+ await touch(subject, feature, scope);
+ return { enrolled: true, feature, scope, liveness: 'not_checked' };
+ }
+ async function template(subject, feature, scope = '') {
+ requireKey(); await requireConsent(subject, feature, scope);
+ const row = await db.maybe('SELECT ciphertext FROM face_templates WHERE subject=$1 AND feature=$2 AND scope=$3', [subject, feature, scope]);
+ if (!row) deny('enrolment_required', `Enrol in ${feature} first.`);
+ return open(key, row.ciphertext);
+ }
+ async function touch(subject, feature, scope = '') {
+ const at = timestamp();
+ await db.exec('UPDATE face_consents SET last_used_at=$4 WHERE subject=$1 AND feature=$2 AND scope=$3 AND revoked_at IS NULL', [subject, feature, scope, at]);
+ await db.exec('UPDATE face_templates SET last_used_at=$4 WHERE subject=$1 AND feature=$2 AND scope=$3', [subject, feature, scope, at]);
+ }
+ async function verify(subject, buffer) {
+ requireModel('yunet'); requireModel('sface');
+ const own = await template(subject, 'verify');
+ const faces = await model.detect(buffer);
+ if (faces.length !== 1) deny('one_face_required', 'Verification needs exactly one face.', 422);
+ const score = model.cosine(own, await model.embed(buffer, faces[0]));
+ await touch(subject, 'verify');
+ return { score, match: score >= config.faces.threshold, threshold: config.faces.threshold, liveness: 'not_checked' };
+ }
+ async function detect(buffer) { requireModel('yunet'); const faces = await model.detect(buffer); return { count: faces.length, boxes: faces.map(({ x, y, width, height, score }) => ({ x, y, width, height, score })) }; }
+ async function blur(buffer) { requireModel('yunet'); const faces = await model.detect(buffer); return { count: faces.length, buffer: await model.blur(buffer, faces) }; }
+ async function upload(subject, buffer, mime) {
+ requireKey(); requireModel('yunet'); requireModel('sface');
+ const findConsent = await active(subject, 'find'), libraryConsent = await active(subject, 'library');
+ if (!findConsent && !libraryConsent) deny('consent_required', 'Enable find or library consent first.');
+ const faces = await model.detect(buffer), vectors = [];
+ for (const face of faces) vectors.push(await model.embed(buffer, face));
+ const id = s.newId('fme'), at = timestamp();
+ await db.exec('INSERT INTO face_media (id,subject,image_ciphertext,image_mime,vectors_ciphertext,created_at,last_used_at) VALUES ($1,$2,$3,$4,$5,$6,$6)',
+ [id, subject, seal(key, buffer), mime, seal(key, vectors), at]);
+ if (findConsent) await touch(subject, 'find');
+ if (libraryConsent) await touch(subject, 'library');
+ return { id, private_link: `/api/v1/faces/media/${id}`, face_count: faces.length };
+ }
+ async function media(subject, id) {
+ requireKey();
+ // Check ownership before consent so another person cannot probe which media ids exist.
+ const row = await db.maybe('SELECT image_ciphertext,image_mime FROM face_media WHERE id=$1 AND subject=$2', [id, subject]);
+ if (!row) deny('not_found', 'No such media.', 404);
+ if (!(await active(subject, 'find')) && !(await active(subject, 'library'))) deny('consent_required', 'Enable find or library consent first.');
+ return { buffer: open(key, row.image_ciphertext, true), mime: row.image_mime };
+ }
+ async function find(subject) {
+ const own = await template(subject, 'find');
+ const rows = await db.many('SELECT id,vectors_ciphertext FROM face_media WHERE subject=$1 ORDER BY created_at DESC LIMIT 500', [subject]);
+ const results = [];
+ for (const row of rows) {
+ const scores = open(key, row.vectors_ciphertext).map((v) => model.cosine(own, v));
+ const score = Math.max(0, ...scores);
+ if (score >= config.faces.threshold) results.push({ private_link: `/api/v1/faces/media/${row.id}`, score });
+ }
+ await touch(subject, 'find');
+ return { results };
+ }
+ // Live calls this with a channel, a frame and a source-owned media reference. Only that channel's opted-in roster is read.
+ async function castMatch(channel, buffer, mediaRef) {
+ requireKey(); requireModel('yunet'); requireModel('sface'); scopeOf('cast', channel);
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9:/_-]{1,159}$/.test(String(mediaRef || ''))) deny('invalid_media_ref', 'Provide a private media reference.', 400);
+ const roster = await db.many(`SELECT c.subject,t.ciphertext FROM face_consents c JOIN face_templates t
+ ON t.subject=c.subject AND t.feature='cast' AND t.scope=c.scope
+ WHERE c.feature='cast' AND c.scope=$1 AND c.revoked_at IS NULL`, [channel]);
+ const faces = await model.detect(buffer), matches = [];
+ for (const face of faces) {
+ const probe = await model.embed(buffer, face);
+ const ranked = roster.map((r) => ({ subject: r.subject, score: model.cosine(open(key, r.ciphertext), probe) })).sort((a, b) => b.score - a.score);
+ if (ranked[0] && ranked[0].score >= config.faces.threshold) {
+ const row = ranked[0], at = timestamp();
+ await db.exec('INSERT INTO face_tags (id,subject,channel,media_ref,created_at,last_used_at) VALUES ($1,$2,$3,$4,$5,$5)', [s.newId('ftg'), row.subject, channel, mediaRef, at]);
+ await touch(row.subject, 'cast', channel);
+ matches.push({ subject: row.subject, score: row.score, box: { x: face.x, y: face.y, width: face.width, height: face.height } });
+ }
+ }
+ return { channel, matches, liveness: 'not_checked' };
+ }
+ async function tags(subject) { return db.many('SELECT id,channel,media_ref,created_at FROM face_tags WHERE subject=$1 ORDER BY created_at DESC', [subject]); }
+ async function cluster(subject, region) {
+ requireKey(); requireModel('yunet'); requireModel('sface');
+ if (!regionAllowed(region)) deny('region_disabled', 'Library grouping is not enabled in this region.');
+ await requireConsent(subject, 'library');
+ const rows = await db.many('SELECT id,vectors_ciphertext FROM face_media WHERE subject=$1', [subject]);
+ await db.exec('DELETE FROM face_clusters WHERE subject=$1', [subject]);
+ const representatives = [];
+ for (const row of rows) for (const vector of open(key, row.vectors_ciphertext)) {
+ const at = timestamp();
+ const match = representatives.find((g) => model.cosine(g.vector, vector) >= config.faces.threshold);
+ const group = match ? match.id : s.newId('fgr');
+ if (!match) representatives.push({ id: group, vector });
+ await db.exec('INSERT INTO face_clusters (id,subject,media_id,vector_ciphertext,group_id,created_at,last_used_at) VALUES ($1,$2,$3,$4,$5,$6,$6)',
+ [s.newId('fcl'), subject, row.id, seal(key, vector), group, at]);
+ }
+ await touch(subject, 'library');
+ return groups(subject);
+ }
+ async function groups(subject) {
+ await requireConsent(subject, 'library');
+ return db.many('SELECT id,media_id,group_id,label,excluded FROM face_clusters WHERE subject=$1 ORDER BY created_at DESC', [subject]);
+ }
+ async function correction(subject, action, ids) {
+ await requireConsent(subject, 'library');
+ if (!['exclude', 'merge', 'split'].includes(action) || !Array.isArray(ids) || !ids.length || ids.length > 100) deny('invalid_action', 'Use exclude, merge or split with cluster IDs.', 400);
+ const rows = await db.many('SELECT id FROM face_clusters WHERE subject=$1 AND id=ANY($2::text[])', [subject, ids]);
+ if (rows.length !== ids.length) deny('not_found', 'A cluster is not in your library.', 404);
+ if (action === 'exclude') await db.exec('UPDATE face_clusters SET excluded=true,label=NULL WHERE subject=$1 AND id=ANY($2::text[])', [subject, ids]);
+ if (action === 'merge') await db.exec('UPDATE face_clusters SET group_id=$3 WHERE subject=$1 AND id=ANY($2::text[]) AND excluded=false', [subject, ids, s.newId('fgr')]);
+ if (action === 'split') for (const id of ids) await db.exec('UPDATE face_clusters SET group_id=$3 WHERE subject=$1 AND id=$2', [subject, id, s.newId('fgr')]);
+ return { action, updated: rows.length };
+ }
+ async function retention() {
+ const at = timestamp();
+ const expired = await db.many(`SELECT subject,feature,scope FROM face_consents WHERE revoked_at IS NULL
+ AND last_used_at <= $1::timestamptz - interval '3 years'`, [at]);
+ for (const row of expired) await revoke(row.subject, row.feature, row.scope);
+ // CUBI: dispose of audit consents within one year after the purpose ended.
+ const old = await db.many(`SELECT id FROM face_consents WHERE revoked_at IS NOT NULL
+ AND purpose_ends_at <= $1::timestamptz - interval '1 year'`, [at]);
+ await db.exec(`DELETE FROM face_consents WHERE revoked_at IS NOT NULL
+ AND purpose_ends_at <= $1::timestamptz - interval '1 year'`, [at]);
+ return { expired: expired.length, consent_records_deleted: old.length };
+ }
+ return { TEXT, available: () => ({ ...model.available(), key: Boolean(key) }), consent, listConsents, revoke, enrol, verify, detect, blur, upload, media, find, castMatch, tags, cluster, groups, correction, retention, regionAllowed };
+}
+module.exports = { createFaces, FEATURES, TEXT, seal, open, keyOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,441 @@
@@ -0,0 +1,441 @@
+'use strict';
+/**
+ * Faces with consent (§5.8b), with a fake ONNX model: no weights and no inference in the test.
+ *
+ * Proves the rules the plan makes non-negotiable: detect/blur need no identity, every matching purpose has its own
+ * signed consent, templates and uploads are AES-GCM ciphertext at rest, a cast channel reads only its own roster,
+ * library grouping is region-gated and unknown faces stay unlabelled, opt-out erases at once, the retention job
+ * expires data, and ADR-033 export/erasure include every face row.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { cosine } = require('../server/vision/faces-model');
+const { keyOf, open } = require('../server/vision/faces');
+
+process.env.VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN = '100000';
+
+const KEY = Buffer.alloc(32, 7).toString('base64');
+const FACE = { x: 20, y: 20, width: 80, height: 80, score: 0.99, landmarks: [[40, 50], [80, 50], [60, 70], [45, 90], [75, 90]] };
+
+/** A stand-in for YuNet/SFace: faces and vectors are injected per call, so no image is ever really inferred. */
+function fakeFacesModel() {
+ const state = { faces: [{ ...FACE }], vector: [1, 0, 0, 0], vectors: null, at: 0, blurred: null, detectCalls: 0 };
+ const next = () => {
+ const v = state.vectors ? state.vectors[state.at++ % state.vectors.length] : state.vector;
+ return Array.from(v);
+ };
+ return {
+ state,
+ available: () => ({ yunet: true, sface: true }),
+ detect: async () => { state.detectCalls++; return state.faces.map((f) => ({ ...f, landmarks: f.landmarks.map((p) => p.slice()) })); },
+ embed: async () => next(),
+ blur: async (_buffer, faces) => { state.blurred = faces.length; return Buffer.from(`BLURRED:${faces.length}`, 'utf8'); },
+ cosine,
+ };
+}
+
+function liveToken(t, channel) {
+ const now = Math.floor(Date.now() / 1000);
+ return t.network.signJwt({
+ iss: t.network.issuer(), sub: 'svc:live', actor_type: 'service', aud: ['openvibe.vision'],
+ cap: ['vision.face.cast.match'], channel_id: channel, iat: now, exp: now + 300, jti: crypto.randomUUID(),
+ });
+}
+
+(async () => {
+ const model = fakeFacesModel();
+ const t = await boot({
+ env: { VISION_FACE_KEY: KEY, VISION_FACE_LIBRARY_REGIONS: 'US-IL, US-TX' },
+ facesModel: model,
+ });
+ const ana = t.network.addUser('ana');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+ const dee = t.network.addUser('dee');
+ const eve = t.network.addUser('eve');
+ const png = await t.textImage('A FACE');
+ const db = t.ctx.s.db;
+
+ const oneFace = () => { model.state.faces = [{ ...FACE }]; };
+ const vector = (v) => { model.state.vector = v; model.state.vectors = null; model.state.at = 0; };
+ const consent = (as, feature, extra = {}) => t.get(`/api/v1/faces/consents/${feature}`, {
+ as, json: { signed_name: 'Test Person', text_version: t.ctx.faces.TEXT[feature].version, ...extra },
+ });
+ const enrol = (as, feature, fields = {}) => t.get(`/api/v1/faces/enrol/${feature}`, { as, multipart: { fields, image: png } });
+ const upload = (as) => t.get('/api/v1/faces/media', { as, multipart: { image: png } });
+ const verify = (as) => t.get('/api/v1/faces/verify', { as, multipart: { image: png } });
+ const count = async (table, subject) => Number(await db.value(`SELECT COUNT(*)::int AS n FROM ${table} WHERE subject=$1`, [subject]));
+
+ try {
+ await check('detect and blur are open privacy tools; they store no template and return no identity', async () => {
+ oneFace();
+ const detect = await t.get('/api/v1/faces/detect', { multipart: { image: png } });
+ assert.strictEqual(detect.status, 200, detect.text);
+ assert.strictEqual(detect.json().count, 1);
+ assert.strictEqual(detect.json().boxes.length, 1);
+ assert.deepStrictEqual(Object.keys(detect.json()).sort(), ['boxes', 'count']);
+ assert.strictEqual(await count('face_templates', ana.subject), 0);
+
+ const blur = await t.get('/api/v1/faces/blur', { multipart: { image: png } });
+ assert.strictEqual(blur.status, 200, blur.text);
+ assert.match(blur.headers.get('content-type'), /^image\/png/);
+ assert.strictEqual(blur.headers.get('x-faces-blurred'), '1');
+ assert.strictEqual(blur.buffer.toString('utf8'), 'BLURRED:1');
+ assert.strictEqual(model.state.blurred, 1);
+ });
+
+ await check('every matching purpose refuses an anonymous caller or a person without its consent', async () => {
+ vector([1, 0, 0, 0]);
+ assert.strictEqual((await verify(ana)).status, 403);
+ assert.strictEqual((await verify(ana)).json().code, 'faces.consent_required');
+ assert.strictEqual((await t.get('/api/v1/faces/find', { as: ana, json: {} })).status, 403);
+ assert.strictEqual((await enrol(ana, 'verify')).status, 403);
+ assert.strictEqual((await enrol(ana, 'cast', { channel: 'live:one' })).status, 403);
+ assert.strictEqual((await upload(ana)).status, 403);
+ assert.strictEqual((await t.get('/api/v1/faces/library/cluster', { as: ana, json: { region: 'US-IL' } })).status, 403);
+ assert.strictEqual((await t.get('/api/v1/faces/library/groups', { as: ana })).status, 403);
+ assert.strictEqual((await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'exclude', ids: ['fcl_x'] } })).status, 403);
+ const noTags = await t.get('/api/v1/faces/cast/tags', { as: ana });
+ assert.strictEqual(noTags.status, 200);
+ assert.deepStrictEqual(noTags.json(), []);
+
+ const anon = await t.get('/api/v1/faces/cast/match', { multipart: { fields: { channel: 'live:one', media_ref: 'media:a' }, image: png } });
+ assert.strictEqual(anon.status, 403);
+ assert.strictEqual(anon.json().code, 'faces.live_grant_required');
+ assert.strictEqual((await t.get('/api/v1/faces/consents')).status, 401);
+ const service = t.network.serviceToken({ sub: 'svc:live', cap: ['vision.face.cast.match'] });
+ assert.strictEqual((await t.get('/api/v1/faces/verify', { bearer: service, multipart: { image: png } })).status, 401);
+ });
+
+ await check('a consent records the feature, text version, signature and server time', async () => {
+ const texts = await t.get('/api/v1/faces/consents/text');
+ assert.strictEqual(texts.status, 200);
+ assert.deepStrictEqual(Object.keys(texts.json()).sort(), ['cast', 'find', 'library', 'verify']);
+
+ const wrong = await t.get('/api/v1/faces/consents/verify', { as: ana, json: { signed_name: 'Ana', text_version: 'old' } });
+ assert.strictEqual(wrong.status, 400);
+ assert.strictEqual(wrong.json().code, 'faces.signature_required');
+ assert.strictEqual((await t.get('/api/v1/faces/consents/verify', { as: ana, json: { text_version: t.ctx.faces.TEXT.verify.version } })).status, 400);
+
+ const castNoAdult = await consent(ana, 'cast', { channel: 'live:one' });
+ assert.strictEqual(castNoAdult.status, 400);
+ assert.strictEqual(castNoAdult.json().code, 'faces.adult_required');
+ const region = await consent(ana, 'library', { region: 'FR' });
+ assert.strictEqual(region.status, 403);
+ assert.strictEqual(region.json().code, 'faces.region_disabled');
+
+ const verify = await consent(ana, 'verify');
+ assert.strictEqual(verify.status, 200, verify.text);
+ assert.strictEqual(verify.json().feature, 'verify');
+ assert.strictEqual(verify.json().text_version, t.ctx.faces.TEXT.verify.version);
+ assert.ok(Date.parse(verify.json().granted_at));
+ const row = await db.maybe('SELECT * FROM face_consents WHERE subject=$1 AND feature=$2 AND revoked_at IS NULL', [ana.subject, 'verify']);
+ assert.strictEqual(row.text_version, t.ctx.faces.TEXT.verify.version);
+ assert.strictEqual(row.signature, 'Test Person');
+ assert.strictEqual(row.revoked_at, null);
+ assert.ok(Date.parse(row.granted_at));
+
+ assert.strictEqual((await consent(ana, 'find')).status, 200);
+ assert.strictEqual((await consent(ana, 'cast', { channel: 'live:one', adult: true })).status, 200);
+ assert.strictEqual((await consent(bo, 'cast', { channel: 'live:two', adult: true })).status, 200);
+
+ const crossSite = await t.get('/api/v1/faces/consents/verify', { as: ana, origin: 'https://evil.example', json: { signed_name: 'Ana', text_version: t.ctx.faces.TEXT.verify.version } });
+ assert.strictEqual(crossSite.status, 403);
+ assert.strictEqual(crossSite.json().code, 'vision.cross_site');
+ });
+
+ await check('enrolment needs exactly one face and seals the template with AES-GCM', async () => {
+ model.state.faces = [];
+ assert.strictEqual((await enrol(ana, 'verify')).status, 422);
+ oneFace();
+ model.state.faces = [{ ...FACE }, { ...FACE, x: 200 }];
+ assert.strictEqual((await enrol(ana, 'verify')).status, 422);
+ oneFace();
+
+ vector([1, 0, 0, 0]);
+ const out = await enrol(ana, 'verify');
+ assert.strictEqual(out.status, 200, out.text);
+ assert.strictEqual(out.json().enrolled, true);
+ assert.strictEqual(out.json().liveness, 'not_checked');
+ const row = await db.maybe('SELECT ciphertext FROM face_templates WHERE subject=$1 AND feature=$2', [ana.subject, 'verify']);
+ assert.ok(row && row.ciphertext);
+ assert.ok(!row.ciphertext.includes('1,0,0,0'), 'the vector is not plaintext');
+ assert.deepStrictEqual(open(keyOf(KEY), row.ciphertext), [1, 0, 0, 0]);
+ assert.deepStrictEqual(keyOf('k'.repeat(32)), Buffer.from('k'.repeat(32)));
+ assert.deepStrictEqual(keyOf('ab'.repeat(32)), Buffer.from('ab'.repeat(32), 'hex'));
+
+ vector([0, 1, 0, 0]);
+ assert.strictEqual((await enrol(ana, 'find')).status, 200);
+ vector([1, 0, 0, 0]);
+ assert.strictEqual((await enrol(ana, 'cast', { channel: 'live:one' })).status, 200);
+ assert.strictEqual((await enrol(bo, 'cast', { channel: 'live:two' })).status, 200);
+ });
+
+ await check('verify answers a score for the caller\'s own template and never an identity', async () => {
+ vector([1, 0, 0, 0]);
+ const yes = await verify(ana);
+ assert.strictEqual(yes.status, 200, yes.text);
+ const b = yes.json();
+ assert.ok(b.score > 0.99);
+ assert.strictEqual(b.match, true);
+ assert.strictEqual(b.threshold, t.config.faces.threshold);
+ assert.strictEqual(b.liveness, 'not_checked');
+ assert.deepStrictEqual(Object.keys(b).sort(), ['liveness', 'match', 'score', 'threshold']);
+ assert.ok(!yes.text.includes(ana.subject));
+ assert.ok(!yes.text.includes(bo.subject));
+
+ vector([0, 1, 0, 0]);
+ const no = await verify(ana);
+ assert.strictEqual(no.status, 200, no.text);
+ assert.strictEqual(no.json().match, false);
+ assert.ok(no.json().score < t.config.faces.threshold);
+ assert.strictEqual((await verify(bo)).status, 403);
+ });
+
+ await check('self search reads only the caller\'s own encrypted upload and returns private links', async () => {
+ vector([0, 1, 0, 0]);
+ assert.strictEqual((await consent(bo, 'find')).status, 200);
+ assert.strictEqual((await enrol(bo, 'find')).status, 200);
+ const up = await upload(ana);
+ assert.strictEqual(up.status, 200, up.text);
+ assert.match(up.json().private_link, /^\/api\/v1\/faces\/media\/fme_/);
+ assert.strictEqual(up.json().face_count, 1);
+ const id = up.json().private_link.split('/').pop();
+ const row = await db.maybe('SELECT image_ciphertext,vectors_ciphertext FROM face_media WHERE id=$1', [id]);
+ assert.ok(!row.image_ciphertext.includes(png.toString('base64')));
+ assert.deepStrictEqual(open(keyOf(KEY), row.image_ciphertext, true), png);
+ assert.deepStrictEqual(open(keyOf(KEY), row.vectors_ciphertext), [[0, 1, 0, 0]]);
+
+ const own = await t.get(`/api/v1/faces/media/${id}`, { as: ana });
+ assert.strictEqual(own.status, 200);
+ assert.deepStrictEqual(own.buffer, png);
+ assert.strictEqual((await t.get(`/api/v1/faces/media/${id}`, { as: bo })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/faces/media/${id}`, { as: cy })).status, 404, 'ownership is checked before consent');
+ assert.strictEqual((await t.get(`/api/v1/faces/media/${id}`)).status, 401);
+
+ vector([0, 1, 0, 0]);
+ const found = await t.get('/api/v1/faces/find', { as: ana, json: {} });
+ assert.strictEqual(found.status, 200, found.text);
+ assert.strictEqual(found.json().results.length, 1);
+ assert.strictEqual(found.json().results[0].private_link, `/api/v1/faces/media/${id}`);
+ assert.ok(!found.text.includes(ana.subject));
+ assert.ok(!found.text.includes(bo.subject));
+ });
+
+ await check('opting out of self search deletes its template and stored uploads at once', async () => {
+ await vector([0, 1, 0, 0]);
+ const off = await t.get('/api/v1/faces/consents/find', { method: 'DELETE', as: ana });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(off.json().erased, true);
+ assert.strictEqual(await count('face_templates', ana.subject) >= 1, true, 'verify template remains');
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='find'", [ana.subject])), 0);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_media WHERE subject=$1", [ana.subject])), 0);
+ assert.strictEqual((await t.get('/api/v1/faces/find', { as: ana, json: {} })).status, 403);
+ });
+
+ await check('library grouping is region-gated, unlabelled and correctable; turning it off erases', async () => {
+ assert.strictEqual((await consent(ana, 'library', { region: 'US-IL' })).status, 200);
+ assert.strictEqual((await t.get('/api/v1/faces/library/cluster', { as: ana, json: { region: 'FR' } })).status, 403);
+
+ model.state.faces = [{ ...FACE }, { ...FACE, x: 200 }];
+ model.state.vector = [0, 0, 0, 1];
+ model.state.vectors = [[1, 0, 0, 0], [0, 1, 0, 0]];
+ model.state.at = 0;
+ const first = await upload(ana);
+ assert.strictEqual(first.status, 200, first.text);
+ assert.strictEqual(first.json().face_count, 2);
+
+ oneFace();
+ vector([1, 0, 0, 0]);
+ const second = await upload(ana);
+ assert.strictEqual(second.status, 200, second.text);
+
+ const clustered = await t.get('/api/v1/faces/library/cluster', { as: ana, json: { region: 'US-IL' } });
+ assert.strictEqual(clustered.status, 200, clustered.text);
+ const groups = clustered.json();
+ assert.strictEqual(groups.length, 3);
+ assert.ok(groups.every((g) => g.label === null));
+ assert.ok(groups.every((g) => !('subject' in g)));
+ assert.strictEqual(new Set(groups.map((g) => g.group_id)).size, 2);
+
+ const [a, b] = groups;
+ const excluded = await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'exclude', ids: [a.id] } });
+ assert.strictEqual(excluded.status, 200, excluded.text);
+ assert.strictEqual(excluded.json().updated, 1);
+ assert.strictEqual((await db.maybe('SELECT excluded FROM face_clusters WHERE id=$1', [a.id])).excluded, true);
+
+ const open = groups.filter((g) => g.id !== a.id);
+ assert.ok(open.length >= 2);
+ const merged = await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'merge', ids: [open[0].id, open[1].id] } });
+ assert.strictEqual(merged.status, 200, merged.text);
+ const same = await db.many('SELECT group_id FROM face_clusters WHERE id=ANY($1::text[])', [[open[0].id, open[1].id]]);
+ assert.strictEqual(new Set(same.map((r) => r.group_id)).size, 1);
+
+ const split = await t.get('/api/v1/faces/library/correct', { as: ana, json: { action: 'split', ids: [b.id] } });
+ assert.strictEqual(split.status, 200, split.text);
+ const before = await db.maybe('SELECT group_id FROM face_clusters WHERE id=$1', [b.id]);
+ assert.ok(before.group_id);
+
+ const off = await t.get('/api/v1/faces/consents/library', { method: 'DELETE', as: ana });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_clusters WHERE subject=$1", [ana.subject])), 0);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_media WHERE subject=$1", [ana.subject])), 0);
+ assert.strictEqual((await t.get('/api/v1/faces/library/groups', { as: ana })).status, 403);
+ });
+
+ await check('a cast channel matches only its own roster; revocation removes matches and past tags', async () => {
+ vector([1, 0, 0, 0]);
+ const one = liveToken(t, 'live:one');
+ const matchOne = await t.get('/api/v1/faces/cast/match', { bearer: one, multipart: { fields: { channel: 'live:one', media_ref: 'media:stream-1' }, image: png } });
+ assert.strictEqual(matchOne.status, 200, matchOne.text);
+ assert.strictEqual(matchOne.json().matches.length, 1);
+ assert.strictEqual(matchOne.json().matches[0].subject, ana.subject);
+ assert.ok(!matchOne.text.includes(bo.subject));
+ assert.strictEqual(matchOne.json().liveness, 'not_checked');
+
+ const two = liveToken(t, 'live:two');
+ const matchTwo = await t.get('/api/v1/faces/cast/match', { bearer: two, multipart: { fields: { channel: 'live:two', media_ref: 'media:stream-2' }, image: png } });
+ assert.strictEqual(matchTwo.status, 200, matchTwo.text);
+ assert.strictEqual(matchTwo.json().matches.length, 1);
+ assert.strictEqual(matchTwo.json().matches[0].subject, bo.subject);
+ assert.ok(!matchTwo.text.includes(ana.subject));
+
+ const wrong = await t.get('/api/v1/faces/cast/match', { bearer: one, multipart: { fields: { channel: 'live:two', media_ref: 'media:stream-2' }, image: png } });
+ assert.strictEqual(wrong.status, 403);
+
+ const tags = await t.get('/api/v1/faces/cast/tags', { as: ana });
+ assert.strictEqual(tags.status, 200);
+ assert.strictEqual(tags.json().length, 1);
+ assert.strictEqual(tags.json()[0].channel, 'live:one');
+ assert.ok(!tags.text.includes(bo.subject));
+
+ const off = await t.get('/api/v1/faces/consents/cast', { method: 'DELETE', as: ana, json: { channel: 'live:one' } });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual((await t.get('/api/v1/faces/cast/tags', { as: ana })).json().length, 0);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_tags WHERE subject=$1", [ana.subject])), 0);
+ const after = await t.get('/api/v1/faces/cast/match', { bearer: one, multipart: { fields: { channel: 'live:one', media_ref: 'media:stream-1' }, image: png } });
+ assert.strictEqual(after.status, 200);
+ assert.deepStrictEqual(after.json().matches, []);
+ const still = await t.get('/api/v1/faces/cast/match', { bearer: two, multipart: { fields: { channel: 'live:two', media_ref: 'media:stream-2' }, image: png } });
+ assert.strictEqual(still.json().matches.length, 1, 'the other channel is unaffected');
+ await t.get('/api/v1/faces/consents/cast', { method: 'DELETE', as: bo, json: { channel: 'live:two' } });
+ });
+
+ await check('the retention job erases expired templates and old consent records, not recent ones', async () => {
+ vector([1, 0, 0, 0]);
+ assert.strictEqual((await consent(cy, 'verify')).status, 200);
+ assert.strictEqual((await enrol(cy, 'verify')).status, 200);
+ await db.exec("UPDATE face_consents SET last_used_at = now() - interval '4 years' WHERE subject=$1 AND feature='verify'", [cy.subject]);
+ const first = await t.ctx.faces.retention();
+ assert.ok(first.expired >= 1);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [cy.subject])), 0);
+ assert.ok((await db.maybe('SELECT revoked_at FROM face_consents WHERE subject=$1 AND feature=$2', [cy.subject, 'verify'])).revoked_at);
+ await db.exec("UPDATE face_consents SET purpose_ends_at = now() - interval '2 years' WHERE subject=$1 AND feature='verify' AND revoked_at IS NOT NULL", [cy.subject]);
+ const second = await t.ctx.faces.retention();
+ assert.ok(second.consent_records_deleted >= 1);
+ assert.strictEqual(await count('face_consents', cy.subject), 0);
+
+ assert.strictEqual((await consent(eve, 'verify')).status, 200);
+ assert.strictEqual((await enrol(eve, 'verify')).status, 200);
+ await db.exec("UPDATE face_consents SET last_used_at = now() - interval '2 years' WHERE subject=$1 AND feature='verify'", [eve.subject]);
+ await t.ctx.faces.retention();
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [eve.subject])), 1);
+ });
+
+ await check('/people and /privacy explain the consent model with plain vi- markup', async () => {
+ const anon = await t.get('/people');
+ assert.strictEqual(anon.status, 200);
+ for (const id of ['vi-people', 'vi-verify', 'vi-find', 'vi-cast', 'vi-library']) assert.ok(anon.text.includes(`id="${id}"`), id);
+ assert.match(anon.text, /Sign in with your Network account/);
+
+ const signed = await t.get('/people', { as: ana });
+ assert.strictEqual(signed.status, 200);
+ assert.ok(signed.text.includes('/people/consent/verify'));
+ assert.ok(signed.text.includes('/people/enrol/verify'));
+ assert.match(signed.text, /role="switch"/);
+ assert.match(signed.text, /name="text_version"/);
+ assert.match(signed.text, /name="region"/);
+
+ const privacy = await t.get('/people/privacy');
+ assert.strictEqual(privacy.status, 200);
+ assert.ok(privacy.text.includes('id="vi-privacy"'));
+ assert.match(privacy.text, /three years/);
+ assert.match(privacy.text, /What never happens/);
+ assert.match(privacy.text, /not checked/);
+ const policy = await t.get('/privacy');
+ assert.strictEqual(policy.status, 200);
+ assert.match(policy.text, /Privacy Policy/);
+
+ const llms = await t.get('/llms.txt');
+ assert.ok(!/No face recognition/i.test(llms.text));
+ assert.match(llms.text, /separate consent/i);
+ const home = await t.get('/');
+ assert.ok(!/No face recognition/i.test(home.text));
+ assert.ok(home.text.includes('/people'));
+ assert.ok(home.text.includes('/people/privacy'));
+ });
+
+ await check('opting out of verification deletes its template at once', async () => {
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [ana.subject])), 1);
+ const off = await t.get('/api/v1/faces/consents/verify', { method: 'DELETE', as: ana });
+ assert.strictEqual(off.status, 200, off.text);
+ assert.strictEqual(off.json().erased, true);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE subject=$1 AND feature='verify'", [ana.subject])), 0);
+ assert.ok((await db.maybe('SELECT revoked_at FROM face_consents WHERE subject=$1 AND feature=$2', [ana.subject, 'verify'])).revoked_at);
+ assert.strictEqual((await verify(ana)).status, 403);
+ });
+
+ await check('ADR-033 export and deletion include every face table', async () => {
+ vector([1, 0, 0, 0]);
+ assert.strictEqual((await consent(dee, 'verify')).status, 200);
+ assert.strictEqual((await enrol(dee, 'verify')).status, 200);
+ assert.strictEqual((await consent(dee, 'cast', { channel: 'live:dee', adult: true })).status, 200);
+ assert.strictEqual((await enrol(dee, 'cast', { channel: 'live:dee' })).status, 200);
+ assert.strictEqual((await consent(dee, 'library', { region: 'US-IL' })).status, 200);
+ oneFace();
+ vector([1, 0, 0, 0]);
+ assert.strictEqual((await upload(dee)).status, 200);
+ assert.strictEqual((await t.get('/api/v1/faces/library/cluster', { as: dee, json: { region: 'US-IL' } })).status, 200);
+ const tag = await t.get('/api/v1/faces/cast/match', { bearer: liveToken(t, 'live:dee'), multipart: { fields: { channel: 'live:dee', media_ref: 'media:dee' }, image: png } });
+ assert.strictEqual(tag.status, 200, tag.text);
+
+ const part = await t.ctx.accountData.exportPart(dee.subject);
+ const files = part.files.map((f) => f.name).sort();
+ for (const file of ['face-clusters.json', 'face-consents.json', 'face-media.json', 'face-tags.json', 'face-templates.json']) assert.ok(files.includes(file), file);
+ assert.ok(part.files.every((f) => f.content.every((row) => row.subject === dee.subject)));
+ assert.ok(!JSON.stringify(part).includes(ana.subject));
+ const template = part.files.find((f) => f.name === 'face-templates.json').content[0];
+ assert.ok(!template.ciphertext.includes('1,0,0,0'));
+
+ const erased = await t.ctx.accountData.erase([dee.subject]);
+ for (const table of ['face_tags', 'face_clusters', 'face_media', 'face_templates', 'face_consents']) {
+ assert.ok(erased.erased[table] >= 1, `${table} erased`);
+ assert.strictEqual(await count(table, dee.subject), 0, `${table} empty`);
+ }
+ assert.ok((await count('face_consents', ana.subject)) >= 1);
+ });
+
+ await check('readiness reports a missing face key as degraded, and the rest of Vision stays ready', async () => {
+ const ready = await t.get('/api/ready');
+ assert.strictEqual(ready.status, 200, ready.text);
+ assert.strictEqual(ready.json().checks.face_models.status, 'ok');
+
+ const noKey = fakeFacesModel();
+ const u = await boot({ env: { VISION_FACE_KEY: null, VISION_FACE_LIBRARY_REGIONS: 'US-IL' }, facesModel: noKey });
+ try {
+ const r = await u.get('/api/ready');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().ready, true);
+ assert.ok(r.json().degraded.includes('face_models'));
+ assert.strictEqual(r.json().checks.face_models.status, 'fail');
+ assert.match(r.text, /VISION_FACE_KEY/);
+ const detect = await u.get('/api/v1/faces/detect', { multipart: { image: png } });
+ assert.strictEqual(detect.status, 200, 'detect needs YuNet, not the key');
+ } finally { await u.close(); }
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
@@ -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, ocr: opts.ocr, ai: opts.aiClient, clip: opts.clip, detector: opts.detector });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ocr: opts.ocr, ai: opts.aiClient, facesModel: opts.facesModel, clip: opts.clip, detector: opts.detector });
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}`;
@@ -117,10 +117,13 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</sty
@@ -117,10 +117,13 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</sty
const map = await t.get('/sitemap.xml');
const map = await t.get('/sitemap.xml');
assert.strictEqual(map.status, 200);
assert.strictEqual(map.status, 200);
assert.ok(map.text.includes('https://openvibe.vision/ui-check'));
assert.ok(map.text.includes('https://openvibe.vision/ui-check'));
+ assert.ok(map.text.includes('https://openvibe.vision/people'));
+ assert.ok(map.text.includes('https://openvibe.vision/privacy'));
const llms = await t.get('/llms.txt');
const llms = await t.get('/llms.txt');
assert.strictEqual(llms.status, 200);
assert.strictEqual(llms.status, 200);
assert.ok(llms.text.includes('OpenVibe.Vision'));
assert.ok(llms.text.includes('OpenVibe.Vision'));
- assert.match(llms.text, /No face recognition/);
+ assert.match(llms.text, /separate consent/);
+ assert.doesNotMatch(llms.text, /No face recognition/);
});
});
} finally {
} finally {
await t.close();
await t.close();