openvibers/openvibe.vision Commits
F1: familiar faces for a camera owner’s own cameras (#28)
Changes
@@@ -68,6 +68,10 @@ DATA_DIR=VISION_FACE_KEY=# Comma-separated region codes; empty means library grouping is off everywhere.VISION_FACE_LIBRARY_REGIONS=++# F1 familiar faces: cameras in these regions take the named person's consent before a template exists.++# An unknown region defaults to strict; everything else can name faces freely.++#VISION_FACE_STRICT_REGIONS=US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN++#VISION_FACE_CONSENT_REQUEST_DAYS=14#VISION_FACE_MATCH_THRESHOLD=0.363#VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN=30@@@ -45,6 +45,17 @@ The four signed text versions are exposed by `GET /api/v1/faces/consents/text`.| `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. |++| `GET/POST /api/v1/faces/libraries`, `GET/PATCH/DELETE /api/v1/faces/libraries/:id` | Person only; the owner's own familiar-face library and its camera ids/region. |++| `POST /api/v1/faces/libraries/:id/enroll`, `/match`, `GET /people`, `PATCH/DELETE /people/:personId` | Person only; name a crop, match only this library, rename/mute or delete a name. Strict mode answers `pending_consent` with no template until consent. |++| `POST /api/v1/faces/libraries/:id/people/:personId/request`, `/consent-check`, `POST /api/v1/faces/consent-requests/:token/accept` · `/deny` | Person only; the private consent link and the registry re-check. |++++### Familiar faces (F1)++++A camera owner turns on Familiar faces for a camera and names the people that camera sees. `POST /api/v1/faces/libraries` creates the owner's own library (`camera_ids`, `region`), `POST /api/v1/faces/libraries/:id/enroll` names a face from the crop the camera saw, and `POST /api/v1/faces/libraries/:id/match` compares only that library. `DELETE /api/v1/faces/libraries/:id` and `DELETE /api/v1/faces/libraries/:id/people/:personId` delete the templates and the encrypted crops at once.++++The region is the camera's own setting. `VISION_FACE_STRICT_REGIONS` (default `US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN`) puts a library in strict mode: the named person's `face.recognize` choice is checked in Network's consent registry, and until it allows the `library` feature the person is `pending_consent` with no template. The owner can re-issue a private link (`POST …/people/:personId/request`), the person accepts it at `POST /api/v1/faces/consent-requests/:token/accept` (or denies it), and `POST …/consent-check` re-reads the registry for pending people. A name can be muted (still matched, alerts held). A library whose region is outside the strict list (or explicitly `region_mode: "open"`) names a face freely.++++F1 adds `face_libraries`, `face_library_people`, `face_library_crops` and `face_consent_requests` in `migrations/0005_face_libraries.sql`, and `face_templates.library_id`. The templates and crops use the same AES-256-GCM key handling as every other face row; a crop is stored only so a strict-mode template can be made after consent, and a delete removes it. `VISION_FACE_CONSENT_REQUEST_DAYS` (default 14) sets the link lifetime.`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.@@@ -4,7 +4,7 @@"stage": "product","code": true,"wave": "W21",-- "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call; §5.8b adds faces with separate purpose-scoped consent; wave 1 adds the six network tools",++ "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call; §5.8b adds faces with separate purpose-scoped consent; F1 (L24) adds a camera owner's own familiar-face libraries; wave 1 adds the six network tools","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",@@@ -39,7 +39,9 @@"vision.benchmarks.read"],"outboundCapabilities": [-- "ai.run.create"++ "ai.run.create",++ "network.consent.read",++ "network.notifications.push"],"capabilitiesRegistered": false,"serviceManifestRegistered": false,@@@ -53,6 +55,7 @@"No looks: public AI text is passed through noLooks; an answer that names looks is replaced by a neutral sentence","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",++ "F1 familiar faces: a camera owner's own library (face_libraries, names, encrypted crops and templates); strict regions check Network's face.recognize registry before a template exists, a private link covers the rest, a match reads only that library, and delete removes templates and crops","Encrypted face templates, opt-in uploads and vectors (AES-256-GCM); immediate template/tag erasure on opt-out and a daily three-year retention job","Network tools: routed alt text, owner-scoped CLIP index/find, Tesseract-first receipts, product vectors, DETR watch and moments, each with a receipt","MCP on /mcp with RFC 9728 protected-resource metadata, token challenges and route-equivalent capability checks",@@@ -75,6 +78,7 @@"deployed": false,"features": ["ask a picture",++ "familiar faces for a camera owner's own cameras","local-first OCR","OpenVibe.AI routed answers","model compare with cost and latency",nnew file mode 100644@@@ -0,0 +1,99 @@++-- phase: expand++-- F1: familiar faces for a camera owner's own cameras (plan L24, 2026-10-31).++--++-- A library belongs to one person (the camera owner) and the cameras they name. Only that subject's templates are++-- ever compared inside it: an unknown face is never looked up in another library, the platform or the web. The++-- region mode comes from the camera's own setting; strict means the named person's consent is checked before a++-- template exists. Templates stay AES-GCM ciphertext with the existing Vision key, and crops are ciphertext too.++--++-- The existing face_templates keeps its purpose rows. A familiar-face template is a row with feature='library'++-- whose scope is the face_library_people id, so the (subject, feature, scope) key still holds one template per++-- named person. library_id is the new column the plan asks for.++++CREATE TABLE face_libraries (++ id text COLLATE "C" PRIMARY KEY, -- flb_ + ULID++ owner text COLLATE "C" NOT NULL, -- usr_…++ camera_ids jsonb NOT NULL DEFAULT '[]'::jsonb, -- Cam camera ids (cam_…); an owner may use one library for several++ region text COLLATE "C" NOT NULL DEFAULT 'unknown',++ region_mode text COLLATE "C" NOT NULL DEFAULT 'strict' CHECK (region_mode IN ('strict', 'open')),++ threshold double precision NOT NULL DEFAULT 0.363,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT face_libraries_id CHECK (id ~ '^flb_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_libraries_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_libraries_cameras CHECK (jsonb_typeof(camera_ids) = 'array'),++ CONSTRAINT face_libraries_region CHECK (length(region) BETWEEN 1 AND 40),++ CONSTRAINT face_libraries_threshold CHECK (threshold >= 0 AND threshold <= 1)++);++CREATE INDEX face_libraries_by_owner ON face_libraries (owner, created_at DESC);++++-- One name in one library. A named person may be a Network subject (consent is checked in the registry) or only a++-- name the owner gave, in which case the link request is the consent. Until consent, status is pending_consent and++-- there is no template; the crop is kept encrypted only so the template can be made after the person accepts.++CREATE TABLE face_library_people (++ id text COLLATE "C" PRIMARY KEY, -- flp_ + ULID++ library_id text COLLATE "C" NOT NULL REFERENCES face_libraries (id) ON DELETE CASCADE,++ owner text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ subject text COLLATE "C", -- usr_… when the owner named an account++ status text COLLATE "C" NOT NULL DEFAULT 'pending_consent'++ CHECK (status IN ('active', 'pending_consent', 'denied')),++ consent_basis text COLLATE "C" CHECK (consent_basis IN ('network', 'request')),++ muted boolean NOT NULL DEFAULT false, -- a muted name is still matched; its alerts are held++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ last_matched_at timestamptz,++ CONSTRAINT face_library_people_id CHECK (id ~ '^flp_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_library_people_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_library_people_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_library_people_name CHECK (length(name) BETWEEN 1 AND 80)++);++CREATE UNIQUE INDEX face_library_people_name ON face_library_people (library_id, lower(name)) WHERE status <> 'denied';++CREATE INDEX face_library_people_library ON face_library_people (library_id, created_at DESC);++CREATE INDEX face_library_people_pending ON face_library_people (status, subject) WHERE status = 'pending_consent';++++-- The crop the camera saw. Ciphertext only, exactly like face_media. Deleting the name deletes its crops at once.++CREATE TABLE face_library_crops (++ id text COLLATE "C" PRIMARY KEY, -- flc_ + ULID++ library_id text COLLATE "C" NOT NULL REFERENCES face_libraries (id) ON DELETE CASCADE,++ person_id text COLLATE "C" NOT NULL REFERENCES face_library_people (id) ON DELETE CASCADE,++ owner text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL DEFAULT 'enrol' CHECK (kind IN ('enrol', 'pending')),++ image_ciphertext text NOT NULL,++ image_mime text NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT face_library_crops_id CHECK (id ~ '^flc_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_library_crops_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_library_crops_mime CHECK (image_mime IN ('image/jpeg', 'image/png', 'image/webp'))++);++CREATE INDEX face_library_crops_by_person ON face_library_crops (person_id, created_at DESC);++++-- A consent request. The raw token is returned once to the owner (the link they send) and only its sha256 is stored.++-- A person who already has a Network account also gets a Network notification through the registry.++CREATE TABLE face_consent_requests (++ id text COLLATE "C" PRIMARY KEY, -- fcr_ + ULID++ library_id text COLLATE "C" NOT NULL REFERENCES face_libraries (id) ON DELETE CASCADE,++ person_id text COLLATE "C" NOT NULL REFERENCES face_library_people (id) ON DELETE CASCADE,++ owner text COLLATE "C" NOT NULL,++ subject text COLLATE "C",++ token_hash text COLLATE "C" NOT NULL UNIQUE,++ status text COLLATE "C" NOT NULL DEFAULT 'pending'++ CHECK (status IN ('pending', 'accepted', 'denied', 'expired')),++ text_version text COLLATE "C" NOT NULL,++ requested_at timestamptz NOT NULL DEFAULT now(),++ decided_at timestamptz,++ expires_at timestamptz NOT NULL,++ CONSTRAINT face_consent_requests_id CHECK (id ~ '^fcr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_consent_requests_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_consent_requests_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT face_consent_requests_token CHECK (token_hash ~ '^[0-9a-f]{64}$'),++ CONSTRAINT face_consent_requests_expiry CHECK (expires_at > requested_at)++);++CREATE INDEX face_consent_requests_pending ON face_consent_requests (person_id, requested_at DESC) WHERE status = 'pending';++++-- The library templates live in the existing table. Add its column and one-template-per-name index.++ALTER TABLE face_templates ADD COLUMN library_id text COLLATE "C";++ALTER TABLE face_templates++ ADD CONSTRAINT face_templates_library_fk FOREIGN KEY (library_id) REFERENCES face_libraries (id) ON DELETE CASCADE;++CREATE INDEX face_templates_by_library ON face_templates (library_id, last_used_at DESC) WHERE library_id IS NOT NULL;++CREATE UNIQUE INDEX face_templates_one_per_person ON face_templates (library_id, scope) WHERE library_id IS NOT NULL;@@@ -42,6 +42,8 @@ const { createAi } = require('./vision/ai');const { createVision } = require('./vision/service');const { createFacesModel } = require('./vision/faces-model');const { createFaces } = require('./vision/faces');++const { createFaceLibraries } = require('./vision/face-libraries');++const { createConsentRegistry } = require('./vision/consent-registry');const { createNetworkSender } = require('openvibe-sdk/account-data');const { createPublisher } = require('./events/publisher');const { createVisionOutbox } = require('./events/outbox');@@@ -69,7 +71,10 @@ 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 facesModel = opts.facesModel || createFacesModel({ dir: config.faces.modelsDir });++ const faces = createFaces({ s, config, model: facesModel });++ const consentRegistry = createConsentRegistry({ config, fetchImpl, log });++ const faceLibraries = createFaceLibraries({ s, config, model: facesModel, faces, network: consentRegistry, log });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 });@@@ -84,7 +89,7 @@ async function createApp(opts = {}) {// until OpenVibe.Events takes them, so a failed publish is not lost. The relay starts in server/index.js.const eventOutbox = opts.eventOutbox || createVisionOutbox({ s, config, log, fetchImpl });const eventPublisher = opts.eventPublisher || createPublisher({ outbox: eventOutbox, log });-- const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, models, networkTools, accountData, accountSend, eventOutbox, eventPublisher, log, fetchImpl };++ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, faceLibraries, consentRegistry, models, networkTools, accountData, accountSend, eventOutbox, eventPublisher, log, fetchImpl };const app = express();app.disable('x-powered-by');@@@ -104,6 +104,12 @@ function load(env = process.env) {modelsDir: modelsDir(env),key: env.VISION_FACE_KEY || '',allowedRegions: String(env.VISION_FACE_LIBRARY_REGIONS || '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),++ // F1 familiar faces: the owner picks the camera's region; a region on this list takes a named person's++ // consent first (Illinois, Texas, Washington, the EU and the UK). An unknown or unlisted region defaults++ // to strict, never to the looser mode.++ strictRegions: String(env.VISION_FACE_STRICT_REGIONS || 'US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN')++ .split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),++ consentRequestDays: Math.min(90, Math.max(1, int(env.VISION_FACE_CONSENT_REQUEST_DAYS, 14))),threshold: (() => {const raw = String(env.VISION_FACE_MATCH_THRESHOLD || '').trim();const n = raw ? Number(raw) : 0.363;@@@ -8,7 +8,7 @@ const { sameOrigin } = require('./principal');const { loadImage } = require('../vision/images');const { VisionError } = require('../vision/service');--function createFaceApi({ config, principal, faces, fetchImpl }) {++function createFaceApi({ config, principal, faces, faceLibraries, 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);@@@ -25,8 +25,8 @@ function createFaceApi({ config, principal, faces, fetchImpl }) {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)); }++ const run = (fn, status = 200) => async (req, res) => {++ try { return res.status(status).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);@@@ -73,6 +73,26 @@ function createFaceApi({ config, principal, faces, fetchImpl }) {}return next();}, run(async (req) => faces.castMatch(req.body.channel, (await image(req)).buffer, req.body.media_ref)));++ // ── F1: a camera owner's own familiar-face library ─────++ // The camera's region decides the mode; strict mode names a person without a template until their consent is++ // in Network's registry (or they accept the private link). A match reads only the library it names.++ r.get('/libraries', person, run((req) => faceLibraries.list(req.principal.subject).then((libraries) => ({ count: libraries.length, libraries }))));++ r.post('/libraries', person, run((req) => faceLibraries.create(req.principal.subject, req.body || {}), 201));++ r.get('/libraries/:id', person, run((req) => faceLibraries.get(req.principal.subject, req.params.id)));++ r.patch('/libraries/:id', person, run((req) => faceLibraries.patch(req.principal.subject, req.params.id, req.body || {})));++ r.delete('/libraries/:id', person, run((req) => faceLibraries.remove(req.principal.subject, req.params.id)));++ r.post('/libraries/:id/enroll', person, imageField, run(async (req) => {++ const im = await image(req);++ return faceLibraries.enroll(req.principal.subject, req.params.id, im.buffer, { ...(req.body || {}), image_mime: im.mime });++ }));++ r.post('/libraries/:id/match', person, imageField, run(async (req) => faceLibraries.match(req.principal.subject, req.params.id, (await image(req)).buffer)));++ r.get('/libraries/:id/people', person, run((req) => faceLibraries.people(req.principal.subject, req.params.id)));++ r.patch('/libraries/:id/people/:personId', person, run((req) => faceLibraries.patchPerson(req.principal.subject, req.params.id, req.params.personId, req.body || {})));++ r.delete('/libraries/:id/people/:personId', person, run((req) => faceLibraries.removePerson(req.principal.subject, req.params.id, req.params.personId)));++ r.post('/libraries/:id/people/:personId/request', person, run((req) => faceLibraries.requestAgain(req.principal.subject, req.params.id, req.params.personId)));++ r.post('/libraries/:id/consent-check', person, run((req) => faceLibraries.checkPending(req.principal.subject, req.params.id)));++ r.post('/consent-requests/:token/accept', person, run((req) => faceLibraries.acceptRequest(req.principal.subject, req.params.token, req.body || {})));++ r.post('/consent-requests/:token/deny', person, run((req) => faceLibraries.denyRequest(req.principal.subject, req.params.token)));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)));@@@ -11,10 +11,15 @@const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');const TABLES = [++ // F1 familiar-face libraries. Crops and requests go before the people and the library they hang under.++ { table: 'face_library_crops', subject: 'owner', value: (usr) => usr, file: 'face-library-crops.json', erase: 'delete' },++ { table: 'face_consent_requests', subject: 'owner', value: (usr) => usr, file: 'face-consent-requests.json', erase: 'delete' },++ { table: 'face_templates', subject: 'subject', value: (usr) => usr, file: 'face-templates.json', erase: 'delete' },++ { table: 'face_library_people', subject: 'owner', value: (usr) => usr, file: 'face-library-people.json', erase: 'delete' },++ { table: 'face_libraries', subject: 'owner', value: (usr) => usr, file: 'face-libraries.json', erase: 'delete' },{ 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.nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * OpenVibe.Network's consent registry, as Vision sees it for F1 (L24; Contracts 0.256.0).++ *++ * allowed(subject) POST /internal/consent/check { purpose: 'face.recognize', feature: 'library' }++ * notify(subject, n) POST /internal/notifications/push { subject, … } (a consent request)++ *++ * Both use Vision's own client-credentials token. A registry that cannot answer means "not allowed" and a request++ * that cannot be sent is simply not sent: the owner still gets the private link and can send it instead. Neither++ * call logs a subject, a face or a crop.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++function createConsentRegistry({ config, fetchImpl = globalThis.fetch, log = console }) {++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');++ const tokens = config.oauth.clientSecret++ ? createServiceTokenClient({++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'network.consent.read network.notifications.push', fetch: fetchImpl,++ })++ : null;++++ async function call(path, body) {++ if (!tokens) return null;++ let token;++ try { token = await tokens.getToken(); } catch { return null; }++ try {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'POST',++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Content-Type': 'application/json' },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(Math.max(1500, Number(config.upstreamTimeoutMs) || 5000)),++ });++ if (!res.ok) return null;++ return await res.json().catch(() => null);++ } catch {++ return null;++ }++ }++++ /** Strict mode: may this person's face be recognised in a camera owner's library? Missing answer = no. */++ async function allowed(subject) {++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return false;++ const data = await call('/internal/consent/check', { purpose: 'face.recognize', feature: 'library', subjects: [subject] });++ return Boolean(data && data.allowed && data.allowed[subject] === true);++ }++++ /** Send a consent request to a named person who has a Network account. Missing answer = not sent. */++ async function notify(subject, { title, message, url } = {}) {++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return { sent: false };++ const data = await call('/internal/notifications/push', {++ subject,++ type: 'FAMILIAR_FACES_CONSENT',++ category: 'system',++ priority: 'normal',++ service: 'vision',++ title: String(title || 'A camera owner named you').slice(0, 160),++ message: String(message || 'Open their consent request to choose whether your face may be recognised.').slice(0, 1000),++ url: url == null ? null : String(url).slice(0, 1000),++ });++ return { sent: Boolean(data && data.ok) };++ }++++ return { allowed, notify, configured: Boolean(tokens) };++}++++module.exports = { createConsentRegistry };nnew file mode 100644@@@ -0,0 +1,465 @@++'use strict';++const crypto = require('crypto');++const { VisionError } = require('./service');++const { seal, open, keyOf } = require('./faces');++++const ID = {++ library: /^flb_[0-9A-HJKMNP-TV-Z]{26}$/,++ person: /^flp_[0-9A-HJKMNP-TV-Z]{26}$/,++ camera: /^cam_[0-9A-HJKMNP-TV-Z]{26}$/,++ subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/,++};++const MAX_CAMERAS = 50;++const MAX_NAME = 80;++const text = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim();++const fail = (code, detail, status = 403) => { throw new VisionError(status, `faces.${code}`, detail); };++const control = (v) => /[\u0000-\u001f\u007f]/.test(String(v == null ? '' : v));++++function normaliseRegion(raw) {++ const r = text(raw).toUpperCase().replace(/\s+/g, '');++ return r && r.length <= 40 ? r : 'unknown';++}++++function wireLibrary(row, { people = null, threshold } = {}) {++ if (!row) return null;++ return {++ id: row.id,++ camera_ids: Array.isArray(row.camera_ids) ? row.camera_ids : [],++ region: row.region,++ region_mode: row.region_mode,++ threshold: threshold == null ? Number(row.threshold) : Number(threshold),++ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),++ updated_at: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),++ people_count: row.people_count == null ? undefined : Number(row.people_count),++ ...(people ? { people } : {}),++ };++}++++function wirePerson(row) {++ if (!row) return null;++ return {++ id: row.id,++ library_id: row.library_id,++ name: row.name,++ subject: row.subject == null ? null : row.subject,++ status: row.status,++ consent_basis: row.consent_basis == null ? null : row.consent_basis,++ muted: Boolean(row.muted),++ has_template: Boolean(row.has_template),++ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),++ updated_at: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),++ last_matched_at: row.last_matched_at == null ? null : (row.last_matched_at instanceof Date ? row.last_matched_at.toISOString() : String(row.last_matched_at)),++ };++}++++/**++ * A camera owner's own familiar-face library (F1).++ *++ * There is exactly one template per named person, and a match reads only the library it was asked about. The++ * consent rules are here, not in the page: the camera's region mode decides whether the named person's consent is++ * checked in Network's registry (strict) or the owner names the face freely (open). Until consent the person has++ * status pending_consent, an encrypted crop for the later enrolment, and no template at all.++ */++function createFaceLibraries({ s, config, model, faces, network, log = console }) {++ const db = s.db;++ const key = keyOf(config.faces.key);++ const timestamp = () => s.iso();++ const requireKey = () => { if (!key) fail('key_unavailable', 'Face encryption is not configured.', 503); };++ const requireModel = () => {++ const a = model.available();++ if (!a.yunet || !a.sface) fail('model_unavailable', 'Face weights are not installed.', 503);++ };++ const strict = (region) => {++ const r = normaliseRegion(region);++ if (r === 'UNKNOWN') return true;++ return (config.faces.strictRegions || []).some((x) => x === r || (x.endsWith('*') && r.startsWith(x.slice(0, -1))));++ };++ const regionMode = (region) => (strict(region) ? 'strict' : 'open');++++ function camerasOf(body) {++ const raw = body && body.camera_ids !== undefined ? body.camera_ids : body && body.camera_id !== undefined ? [body.camera_id] : [];++ if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_CAMERAS) fail('invalid_cameras', `Name 1 to ${MAX_CAMERAS} cameras.`, 400);++ const out = [...new Set(raw.map((x) => text(x)))];++ if (out.some((x) => !ID.camera.test(x))) fail('invalid_cameras', 'A camera id is a Cam camera id (cam_…).', 400);++ return out;++ }++++ function cleanName(raw) {++ const name = text(raw);++ if (!name || name.length > MAX_NAME || control(raw)) fail('invalid_name', `A name is 1 to ${MAX_NAME} characters of plain text.`, 400);++ return name;++ }++++ async function validateSubject(raw) {++ if (raw == null || raw === '') return null;++ const subject = text(raw);++ if (!ID.subject.test(subject)) fail('invalid_subject', 'subject must be a person\'s canonical usr_ id.', 400);++ return subject;++ }++++ async function libraryOf(owner, id) {++ const row = await db.maybe('SELECT * FROM face_libraries WHERE id=$1 AND owner=$2', [String(id || ''), owner]);++ if (!row) fail('not_found', 'No such face library.', 404);++ return row;++ }++++ async function personOf(owner, library, id) {++ const row = await db.maybe(`SELECT p.*, (t.ciphertext IS NOT NULL) AS has_template++ FROM face_library_people p LEFT JOIN face_templates t++ ON t.library_id=p.library_id AND t.scope=p.id AND t.feature='library'++ WHERE p.id=$1 AND p.library_id=$2 AND p.owner=$3`, [String(id || ''), library.id, owner]);++ if (!row) fail('not_found', 'No such named face in this library.', 404);++ return row;++ }++++ async function peopleOf(library) {++ const rows = await db.many(`SELECT p.*, (t.ciphertext IS NOT NULL) AS has_template++ FROM face_library_people p LEFT JOIN face_templates t++ ON t.library_id=p.library_id AND t.scope=p.id AND t.feature='library'++ WHERE p.library_id=$1 ORDER BY p.created_at DESC, p.id DESC`, [library.id]);++ return rows.map(wirePerson);++ }++++ async function create(owner, body = {}) {++ requireKey();++ const cameraIds = camerasOf(body);++ const region = normaliseRegion(body.region);++ const mode = regionMode(region);++ const existing = await db.many('SELECT id, camera_ids FROM face_libraries WHERE owner=$1', [owner]);++ for (const row of existing) {++ const ids = Array.isArray(row.camera_ids) ? row.camera_ids : [];++ if (ids.some((id) => cameraIds.includes(id))) fail('camera_already_library', 'That camera already has a familiar-faces library.', 409);++ }++ const threshold = body.threshold == null ? config.faces.threshold : Number(body.threshold);++ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) fail('invalid_threshold', 'threshold is from 0 to 1.', 400);++ const id = s.newId('flb'), at = timestamp();++ const row = await db.maybe(`INSERT INTO face_libraries (id, owner, camera_ids, region, region_mode, threshold, created_at, updated_at)++ VALUES ($1,$2,$3::jsonb,$4,$5,$6,$7::timestamptz,$7::timestamptz) RETURNING *`,++ [id, owner, JSON.stringify(cameraIds), region, mode, threshold, at]);++ return wireLibrary(row, { people: [] });++ }++++ async function list(owner) {++ const rows = await db.many(`SELECT l.*, (SELECT COUNT(*)::int FROM face_library_people p WHERE p.library_id=l.id) AS people_count++ FROM face_libraries l WHERE l.owner=$1 ORDER BY l.created_at DESC, l.id DESC`, [owner]);++ return rows.map((r) => wireLibrary(r));++ }++++ async function get(owner, id) {++ const row = await libraryOf(owner, id);++ return wireLibrary(row, { people: await peopleOf(row) });++ }++++ async function patch(owner, id, body = {}) {++ requireKey();++ const row = await libraryOf(owner, id);++ const sets = [];++ const args = [row.id];++ if (body.camera_ids !== undefined || body.camera_id !== undefined) {++ const cameraIds = camerasOf(body);++ const others = await db.many('SELECT id, camera_ids FROM face_libraries WHERE owner=$1 AND id<>$2', [owner, row.id]);++ for (const other of others) {++ const ids = Array.isArray(other.camera_ids) ? other.camera_ids : [];++ if (ids.some((x) => cameraIds.includes(x))) fail('camera_already_library', 'That camera already has a familiar-faces library.', 409);++ }++ args.push(JSON.stringify(cameraIds)); sets.push(`camera_ids=$${args.length}::jsonb`);++ }++ if (body.region !== undefined) {++ const region = normaliseRegion(body.region);++ args.push(region); sets.push(`region=$${args.length}`);++ args.push(regionMode(region)); sets.push(`region_mode=$${args.length}`);++ }++ if (body.threshold !== undefined) {++ const n = Number(body.threshold);++ if (!Number.isFinite(n) || n < 0 || n > 1) fail('invalid_threshold', 'threshold is from 0 to 1.', 400);++ args.push(n); sets.push(`threshold=$${args.length}`);++ }++ if (!sets.length) fail('invalid', 'Send camera_ids, region or threshold.', 400);++ args.push(timestamp());++ const updated = await db.maybe(`UPDATE face_libraries SET ${sets.join(', ')}, updated_at=$${args.length}::timestamptz++ WHERE id=$1 RETURNING *`, args);++ return wireLibrary(updated, { people: await peopleOf(updated) });++ }++++ async function remove(owner, id) {++ requireKey();++ const row = await libraryOf(owner, id);++ const counts = await db.maybe(`SELECT++ (SELECT COUNT(*)::int FROM face_library_people WHERE library_id=$1) AS people,++ (SELECT COUNT(*)::int FROM face_library_crops WHERE library_id=$1) AS crops,++ (SELECT COUNT(*)::int FROM face_templates WHERE library_id=$1) AS templates,++ (SELECT COUNT(*)::int FROM face_consent_requests WHERE library_id=$1) AS requests`, [row.id]);++ await db.exec('DELETE FROM face_libraries WHERE id=$1 AND owner=$2', [row.id, owner]);++ return { deleted: true, id: row.id, people: Number(counts.people), crops: Number(counts.crops), templates: Number(counts.templates), requests: Number(counts.requests) };++ }++++ async function saveCrop(library, person, buffer, mime, kind) {++ await db.exec('DELETE FROM face_library_crops WHERE person_id=$1 AND library_id=$2', [person.id, library.id]);++ await db.exec(`INSERT INTO face_library_crops (id, library_id, person_id, owner, kind, image_ciphertext, image_mime, created_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::timestamptz)`,++ [s.newId('flc'), library.id, person.id, library.owner, kind, seal(key, buffer), mime, timestamp()]);++ }++++ async function latestCrop(person) {++ const row = await db.maybe('SELECT image_ciphertext FROM face_library_crops WHERE person_id=$1 ORDER BY created_at DESC, id DESC LIMIT 1', [person.id]);++ if (!row) return null;++ return open(key, row.image_ciphertext, true);++ }++++ async function putTemplate(library, person, vector) {++ const at = timestamp();++ await db.exec(`INSERT INTO face_templates (subject, feature, scope, ciphertext, created_at, last_used_at, library_id)++ VALUES ($1,'library',$2,$3,$4::timestamptz,$4::timestamptz,$5)++ ON CONFLICT (subject, feature, scope) DO UPDATE SET ciphertext=EXCLUDED.ciphertext, last_used_at=EXCLUDED.last_used_at, library_id=EXCLUDED.library_id`,++ [library.owner, person.id, seal(key, vector), at, library.id]);++ }++++ async function embedCrop(buffer) {++ const found = await model.detect(buffer);++ if (found.length !== 1) fail('one_face_required', 'Naming a face needs exactly one face in the crop.', 422);++ return await model.embed(buffer, found[0]);++ }++++ async function requestLink(library, person) {++ const token = crypto.randomBytes(24).toString('base64url');++ const hash = crypto.createHash('sha256').update(token).digest('hex');++ const at = timestamp();++ const expires = new Date(s.now() + Number(config.faces.consentRequestDays) * 86_400_000).toISOString();++ await s.tx(async (t) => {++ await t.exec("UPDATE face_consent_requests SET status='expired', decided_at=$2::timestamptz WHERE person_id=$1 AND status='pending'", [person.id, at]);++ await t.exec(`INSERT INTO face_consent_requests (id, library_id, person_id, owner, subject, token_hash, status, text_version, requested_at, expires_at)++ VALUES ($1,$2,$3,$4,$5,$6,'pending',$7,$8::timestamptz,$9::timestamptz)`,++ [s.newId('fcr'), library.id, person.id, library.owner, person.subject == null ? null : person.subject, hash, faces.TEXT.library.version, at, expires]);++ });++ const url = `${String(config.baseUrl).replace(/\/+$/, '')}/people/privacy?face_request=${encodeURIComponent(token)}`;++ let sent = false;++ if (person.subject) {++ const got = await network.notify(person.subject, {++ title: 'A camera owner asked to know your face',++ message: 'Open OpenVibe.Vision to choose whether your face may be recognised in their camera. Until you do, the camera only sees a familiar visitor, and no template is stored.',++ url,++ });++ sent = Boolean(got && got.sent);++ }++ return { token, url, expires_at: expires, sent, text_version: faces.TEXT.library.version };++ }++++ /**++ * Name a face from the crop the camera saw. In strict mode this returns pending_consent (no template) until the++ * person's consent is in the registry or they accept the link. Open mode enrols at once.++ */++ async function enroll(owner, id, buffer, body = {}) {++ requireKey(); requireModel();++ const library = await libraryOf(owner, id);++ const name = cleanName(body.name);++ const subject = await validateSubject(body.subject);++ const at = timestamp();++ const vector = await embedCrop(buffer);++ const mime = body.image_mime ? String(body.image_mime) : 'image/jpeg';++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(mime)) fail('invalid_image', 'The crop must be JPEG, PNG or WebP.', 400);++++ let person = await db.maybe('SELECT * FROM face_library_people WHERE library_id=$1 AND lower(name)=lower($2) AND status<>\'denied\'', [library.id, name]);++ const consented = library.region_mode === 'open' || (subject ? await network.allowed(subject) : false);++ const status = consented ? 'active' : 'pending_consent';++ const basis = consented ? (library.region_mode === 'open' ? null : 'network') : null;++++ if (!person) {++ person = await db.maybe(`INSERT INTO face_library_people (id, library_id, owner, name, subject, status, consent_basis, created_at, updated_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::timestamptz,$8::timestamptz) RETURNING *`,++ [s.newId('flp'), library.id, owner, name, subject, status, basis, at]);++ } else {++ person = await db.maybe(`UPDATE face_library_people SET name=$2, subject=COALESCE($3,subject), status=$4, consent_basis=$5, updated_at=$6::timestamptz++ WHERE id=$1 RETURNING *`, [person.id, name, subject, status, basis, at]);++ }++ await saveCrop(library, person, buffer, mime, status === 'active' ? 'enrol' : 'pending');++++ if (status === 'active') {++ await putTemplate(library, person, vector);++ await db.exec("UPDATE face_consent_requests SET status='expired', decided_at=$2::timestamptz WHERE person_id=$1 AND status='pending'", [person.id, at]);++ return { person: wirePerson({ ...person, has_template: true }), consent_request: null, enrolled: true };++ }++ // Pending consent means no template at all, including an earlier one for this name.++ await db.exec("DELETE FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);++ const request = await requestLink(library, person);++ return { person: wirePerson({ ...person, has_template: false }), consent_request: request, enrolled: false };++ }++++ /** Is this named person's consent still there? Strict mode re-checks the registry on the next match; the++ * private-link acceptance is a local face_consents row, and a revoked one no longer counts. */++ async function consentActive(library, person) {++ if (library.region_mode !== 'strict') return true;++ if (!person.subject) return false;++ if (person.consent_basis === 'request') {++ const row = await db.maybe("SELECT 1 AS ok FROM face_consents WHERE subject=$1 AND feature='library' AND revoked_at IS NULL LIMIT 1", [person.subject]);++ return Boolean(row);++ }++ return await network.allowed(person.subject);++ }++++ async function deactivatePending(library, personId) {++ await s.tx(async (t) => {++ await t.exec("DELETE FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, personId]);++ await t.exec("UPDATE face_library_people SET status='pending_consent', consent_basis=NULL, updated_at=$2::timestamptz WHERE id=$1 AND status='active'", [personId, timestamp()]);++ });++ }++++ async function match(owner, id, buffer) {++ requireKey(); requireModel();++ const library = await libraryOf(owner, id);++ const found = await model.detect(buffer);++ if (found.length !== 1) fail('one_face_required', 'A match needs exactly one face in the crop.', 422);++ const probe = await model.embed(buffer, found[0]);++ // Only this library's templates: the owner and library_id are both in the query, so another person's++ // library is never read (and an empty library answers unknown without a lookup anywhere else).++ const rows = await db.many(`SELECT p.id, p.name, p.muted, p.subject, p.consent_basis, t.ciphertext++ FROM face_templates t JOIN face_library_people p++ ON p.library_id=t.library_id AND p.id=t.scope AND p.status='active'++ WHERE t.subject=$1 AND t.library_id=$2 AND t.feature='library'`, [owner, library.id]);++ const ranked = rows.map((row) => ({ ...row, score: model.cosine(open(key, row.ciphertext), probe) })).sort((a, b) => b.score - a.score);++ const threshold = Number(library.threshold);++ let best = null;++ for (const row of ranked.slice(0, 10)) {++ if (!(await consentActive(library, row))) {++ // The person's consent is gone: the template goes at once and the next check answers unknown.++ await deactivatePending(library, row.id);++ continue;++ }++ if (row.score >= threshold) { best = row; break; }++ if (!best) best = { ...row, score: row.score };++ }++ if (!best || best.score < threshold) {++ return { library_id: library.id, match: false, label: 'unknown person', name: null, person_id: null, muted: false, score: best ? best.score : null, threshold };++ }++ await db.exec('UPDATE face_library_people SET last_matched_at=$2::timestamptz WHERE id=$1', [best.id, timestamp()]);++ return { library_id: library.id, match: true, label: best.name, name: best.name, person_id: best.id, muted: best.muted, score: best.score, threshold };++ }++++ async function people(owner, id) {++ const library = await libraryOf(owner, id);++ return { library: wireLibrary(library), people: await peopleOf(library) };++ }++++ async function patchPerson(owner, id, personId, body = {}) {++ const library = await libraryOf(owner, id);++ const person = await personOf(owner, library, personId);++ const sets = [];++ const args = [person.id];++ if (body.name !== undefined) {++ const name = cleanName(body.name);++ const clash = await db.maybe(`SELECT id FROM face_library_people WHERE library_id=$1 AND lower(name)=lower($2) AND status<>'denied' AND id<>$3`,++ [library.id, name, person.id]);++ if (clash) fail('name_taken', 'That name is already in this library.', 409);++ args.push(name); sets.push(`name=$${args.length}`);++ }++ if (body.muted !== undefined) {++ args.push(Boolean(body.muted)); sets.push(`muted=$${args.length}`);++ }++ if (!sets.length) fail('invalid', 'Send a name or muted.', 400);++ args.push(timestamp());++ const updated = await db.maybe(`UPDATE face_library_people SET ${sets.join(', ')}, updated_at=$${args.length}::timestamptz WHERE id=$1 RETURNING *`, args);++ const has = await db.maybe("SELECT 1 AS ok FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);++ return wirePerson({ ...updated, has_template: Boolean(has) });++ }++++ async function removePerson(owner, id, personId) {++ requireKey();++ const library = await libraryOf(owner, id);++ const person = await personOf(owner, library, personId);++ const templates = await db.maybe("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);++ const crops = await db.maybe('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [person.id]);++ const requests = await db.maybe('SELECT COUNT(*)::int AS n FROM face_consent_requests WHERE person_id=$1', [person.id]);++ await s.tx(async (t) => {++ await t.exec("DELETE FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);++ await t.exec('DELETE FROM face_consent_requests WHERE person_id=$1', [person.id]);++ await t.exec('DELETE FROM face_library_crops WHERE person_id=$1', [person.id]);++ await t.exec('DELETE FROM face_library_people WHERE id=$1', [person.id]);++ });++ return { deleted: true, id: person.id, name: person.name, templates: Number(templates.n), crops: Number(crops.n), requests: Number(requests.n) };++ }++++ /** Re-check the registry for pending people. The camera page may call this when it opens; a match never waits on it. */++ async function checkPending(owner, id) {++ const library = await libraryOf(owner, id);++ const pending = await db.many("SELECT * FROM face_library_people WHERE library_id=$1 AND owner=$2 AND status='pending_consent' AND subject IS NOT NULL ORDER BY created_at LIMIT 50", [library.id, owner]);++ const activated = [];++ for (const person of pending) {++ if (!(await network.allowed(person.subject))) continue;++ const crop = await latestCrop(person);++ if (!crop) continue;++ let vector;++ try { vector = await embedCrop(crop); } catch { continue; }++ await s.tx(async (t) => {++ await t.exec("UPDATE face_library_people SET status='active', consent_basis='network', updated_at=$2::timestamptz WHERE id=$1 AND status='pending_consent'", [person.id, timestamp()]);++ await t.exec("UPDATE face_consent_requests SET status='accepted', decided_at=$2::timestamptz WHERE person_id=$1 AND status='pending'", [person.id, timestamp()]);++ });++ await putTemplate(library, person, vector);++ activated.push(wirePerson({ ...person, status: 'active', consent_basis: 'network', has_template: true }));++ }++ return { checked: pending.length, activated };++ }++++ async function requestAgain(owner, id, personId) {++ const library = await libraryOf(owner, id);++ const person = await personOf(owner, library, personId);++ if (person.status !== 'pending_consent') fail('already_decided', 'That name is not waiting for consent.', 409);++ return await requestLink(library, person);++ }++++ async function acceptRequest(actor, token, body = {}) {++ requireKey(); requireModel();++ const raw = text(token);++ if (!/^[A-Za-z0-9_-]{20,80}$/.test(raw)) fail('request_not_found', 'No such consent request.', 404);++ const hash = crypto.createHash('sha256').update(raw).digest('hex');++ const row = await db.maybe(`SELECT r.*, l.owner, l.region, p.name AS person_name, p.subject AS person_subject++ FROM face_consent_requests r JOIN face_libraries l ON l.id=r.library_id++ JOIN face_library_people p ON p.id=r.person_id++ WHERE r.token_hash=$1`, [hash]);++ if (!row || row.status !== 'pending') fail('request_not_found', 'No such open consent request.', 404);++ if (Date.parse(row.expires_at) <= s.now()) {++ await db.exec("UPDATE face_consent_requests SET status='expired', decided_at=$2::timestamptz WHERE id=$1", [row.id, timestamp()]);++ fail('request_expired', 'That consent request has expired; ask the camera owner for a new link.', 410);++ }++ if (row.subject && row.subject !== actor) fail('request_not_yours', 'This consent request names another person.', 403);++ const signedName = text(body.signed_name);++ if (!signedName || signedName.length > 160 || control(body.signed_name)) fail('signature_required', 'Sign the current face-library consent text.', 400);++ if (body.text_version !== faces.TEXT.library.version || body.text_version !== row.text_version) fail('signature_required', 'Read the current consent text and sign it.', 400);++ const crop = await latestCrop({ id: row.person_id });++ if (!crop) fail('crop_missing', 'The camera crop is no longer available; ask the owner to name you again.', 409);++ const vector = await embedCrop(crop);++ const at = timestamp();++ await s.tx(async (t) => {++ await t.exec(`UPDATE face_consent_requests SET status='accepted', subject=COALESCE(subject,$2), decided_at=$3::timestamptz WHERE id=$1`, [row.id, actor, at]);++ await t.exec(`UPDATE face_library_people SET subject=COALESCE(subject,$2), status='active', consent_basis='request', updated_at=$3::timestamptz WHERE id=$1`, [row.person_id, actor, at]);++ const active = await t.maybe("SELECT id FROM face_consents WHERE subject=$1 AND feature='library' AND revoked_at IS NULL LIMIT 1", [actor]);++ if (!active) {++ await t.exec(`INSERT INTO face_consents (id, subject, feature, scope, text_version, signature, granted_at, last_used_at)++ VALUES ($1,$2,'library','',$3,$4,$5::timestamptz,$5::timestamptz)`,++ [s.newId('fco'), actor, faces.TEXT.library.version, signedName, at]);++ }++ });++ const library = await db.maybe('SELECT * FROM face_libraries WHERE id=$1', [row.library_id]);++ const person = await db.maybe('SELECT * FROM face_library_people WHERE id=$1', [row.person_id]);++ await putTemplate(library, person, vector);++ return { accepted: true, library_id: row.library_id, person: wirePerson({ ...person, has_template: true }), text_version: faces.TEXT.library.version };++ }++++ async function denyRequest(actor, token) {++ const raw = text(token);++ if (!/^[A-Za-z0-9_-]{20,80}$/.test(raw)) fail('request_not_found', 'No such consent request.', 404);++ const hash = crypto.createHash('sha256').update(raw).digest('hex');++ const row = await db.maybe('SELECT * FROM face_consent_requests WHERE token_hash=$1', [hash]);++ if (!row || row.status !== 'pending') fail('request_not_found', 'No such open consent request.', 404);++ if (row.subject && row.subject !== actor) fail('request_not_yours', 'This consent request names another person.', 403);++ const at = timestamp();++ await s.tx(async (t) => {++ await t.exec("UPDATE face_consent_requests SET status='denied', subject=COALESCE(subject,$2), decided_at=$3::timestamptz WHERE id=$1", [row.id, actor, at]);++ await t.exec("UPDATE face_library_people SET status='denied', updated_at=$2::timestamptz WHERE id=$1", [row.person_id, at]);++ await t.exec('DELETE FROM face_library_crops WHERE person_id=$1', [row.person_id]);++ });++ return { denied: true, library_id: row.library_id };++ }++++ return { text: faces.TEXT.library, regionMode, strict, create, list, get, patch, remove, enroll, match, people, patchPerson, removePerson, checkPending, requestAgain, acceptRequest, denyRequest, _libraryOf: libraryOf };++}++++module.exports = { createFaceLibraries, ID, normaliseRegion, wireLibrary, wirePerson };nnew file mode 100644@@@ -0,0 +1,253 @@++'use strict';++/**++ * F1: familiar faces for a camera owner's own cameras.++ *++ * The rules the plan makes hard: a match reads only the library it names, an unknown face stays unknown, strict mode++ * stores no template until the named person's consent is in the registry (or they accept the link), and deleting a++ * name or a camera's library removes its templates and crops. The model is a fixture: no weights, no inference.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { cosine } = require('../server/vision/faces-model');++++const KEY = Buffer.alloc(32, 9).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]] };++const camera = () => `cam_${ids.ulid()}`;++++function fakeModel() {++ const state = { vector: [1, 0, 0, 0], detectCalls: 0 };++ return {++ state,++ available: () => ({ yunet: true, sface: true }),++ detect: async () => { state.detectCalls++; return [{ ...FACE, landmarks: FACE.landmarks.map((p) => p.slice()) }]; },++ embed: async () => state.vector.slice(),++ blur: async () => Buffer.from('blurred'),++ cosine,++ };++}++++(async () => {++ const model = fakeModel();++ const t = await boot({++ env: {++ VISION_FACE_KEY: KEY,++ VISION_FACE_LIBRARY_REGIONS: 'US-IL,US-TX,US-WA',++ VISION_FACE_STRICT_REGIONS: 'US-IL,US-TX,US-WA,EU,GB,UNKNOWN',++ VISION_FACE_MATCH_THRESHOLD: '0.6',++ VISION_FACE_CONSENT_REQUEST_DAYS: '7',++ },++ 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 png = await t.textImage('A FACE');++ const db = t.ctx.s.db;++ const vector = (v) => { model.state.vector = v; };++ const create = (as, body) => t.get('/api/v1/faces/libraries', { as, json: body });++ const enrol = (as, libraryId, fields) => t.get(`/api/v1/faces/libraries/${libraryId}/enroll`, { as, multipart: { fields, image: png } });++ const match = (as, libraryId) => t.get(`/api/v1/faces/libraries/${libraryId}/match`, { as, multipart: { image: png } });++ const rows = async (sql, args) => await db.many(sql, args);++++ try {++ let anaLibrary = null;++ let boPerson = null;++ let samPerson = null;++++ await check('a camera owner gets a library with the camera region and strict mode', async () => {++ const r = await create(ana, { camera_ids: [camera(), camera()], region: 'US-IL' });++ assert.strictEqual(r.status, 201, r.text);++ anaLibrary = r.json();++ assert.match(anaLibrary.id, /^flb_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(anaLibrary.region, 'US-IL');++ assert.strictEqual(anaLibrary.region_mode, 'strict');++ assert.strictEqual(anaLibrary.camera_ids.length, 2);++ assert.strictEqual((await create(ana, { camera_ids: [camera()], region: 'US-CA' })).json().region_mode, 'open');++ assert.strictEqual((await create(ana, { camera_ids: ['not-a-camera'], region: 'US-IL' })).status, 400);++ assert.strictEqual((await create(ana, { camera_ids: anaLibrary.camera_ids, region: 'US-IL' })).status, 409);++ });++++ await check('strict mode names a face with the registry consent and stores an encrypted template', async () => {++ t.network.allowFace(bo.subject, true);++ vector([1, 0, 0, 0]);++ const r = await enrol(ana, anaLibrary.id, { name: 'Bo', subject: bo.subject });++ assert.strictEqual(r.status, 200, r.text);++ boPerson = r.json().person;++ assert.strictEqual(boPerson.status, 'active');++ assert.strictEqual(boPerson.consent_basis, 'network');++ assert.strictEqual(boPerson.has_template, true);++ const rows = await db.many("SELECT ciphertext, library_id FROM face_templates WHERE subject=$1 AND feature='library' AND scope=$2", [ana.subject, boPerson.id]);++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].library_id, anaLibrary.id);++ assert.ok(!rows[0].ciphertext.includes('1,0,0,0'), 'the vector is ciphertext');++ assert.ok(t.network.state.faceChecks.some((c) => c.purpose === 'face.recognize' && c.feature === 'library' && c.subjects.includes(bo.subject)));++ });++++ await check('strict mode blocks the template for a named person without consent and sends a request', async () => {++ vector([0, 1, 0, 0]);++ const r = await enrol(ana, anaLibrary.id, { name: 'Sam' });++ assert.strictEqual(r.status, 200, r.text);++ samPerson = r.json().person;++ assert.strictEqual(samPerson.status, 'pending_consent');++ assert.strictEqual(samPerson.has_template, false);++ assert.ok(r.json().consent_request.url.includes('face_request='));++ assert.strictEqual(r.json().consent_request.sent, false);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [samPerson.id])), 1);++ });++++ await check('a match reads only its own library and returns the top named face', async () => {++ vector([1, 0, 0, 0]);++ const got = await match(ana, anaLibrary.id);++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.json().match, true);++ assert.strictEqual(got.json().name, 'Bo');++ assert.strictEqual(got.json().person_id, boPerson.id);++ assert.ok(got.json().score >= 0.6);++ assert.strictEqual(got.json().label, 'Bo');++ });++++ await check('revoking registry consent on the next match drops the template and answers unknown', async () => {++ t.network.allowFace(bo.subject, false);++ vector([1, 0, 0, 0]);++ const got = await match(ana, anaLibrary.id);++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.json().match, false);++ assert.strictEqual(got.json().label, 'unknown person');++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, boPerson.id])), 0);++ const person = await db.maybe('SELECT status FROM face_library_people WHERE id=$1', [boPerson.id]);++ assert.strictEqual(person.status, 'pending_consent');++ t.network.allowFace(bo.subject, true);++ });++++ await check('an unknown face stays unknown and never consults another owner library', async () => {++ const otherLibrary = (await create(cy, { camera_ids: [camera()], region: 'US-CA' })).json();++ vector([1, 0, 0, 0]);++ const deePerson = (await enrol(cy, otherLibrary.id, { name: 'Dee' })).json().person;++ assert.strictEqual(deePerson.status, 'active');++ vector([0, 0, 1, 0]);++ const got = await match(ana, anaLibrary.id);++ assert.strictEqual(got.status, 200, got.text);++ assert.strictEqual(got.json().match, false);++ assert.strictEqual(got.json().name, null);++ assert.strictEqual(got.json().label, 'unknown person');++ vector([1, 0, 0, 0]);++ const cyGot = await match(cy, otherLibrary.id);++ assert.strictEqual(cyGot.json().name, 'Dee');++ assert.notStrictEqual(cyGot.json().person_id, boPerson.id);++ // The unknown answer named nobody from the other owner's library.++ assert.ok(!JSON.stringify(got.json()).includes('Dee'));++ });++++ await check('the private consent link turns a pending name into a template after the person accepts', async () => {++ const people = (await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people`, { as: ana })).json();++ assert.ok(people.people.some((p) => p.id === samPerson.id && !p.has_template));++ // The raw token is not stored; the owner re-issues a link when needed and that request is the one accepted.++ const again = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people/${samPerson.id}/request`, { as: ana, method: 'POST' });++ assert.strictEqual(again.status, 200, again.text);++ const link = again.json().url;++ const raw = new URL(link).searchParams.get('face_request');++ assert.ok(raw);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_consent_requests WHERE person_id=$1 AND status='pending'", [samPerson.id])), 1);++ vector([0, 1, 0, 0]);++ const accepted = await t.get(`/api/v1/faces/consent-requests/${raw}/accept`, {++ as: dee, json: { signed_name: 'Dee', text_version: t.ctx.faces.TEXT.library.version },++ });++ assert.strictEqual(accepted.status, 200, accepted.text);++ assert.strictEqual(accepted.json().accepted, true);++ const person = await db.maybe('SELECT * FROM face_library_people WHERE id=$1', [samPerson.id]);++ assert.strictEqual(person.status, 'active');++ assert.strictEqual(person.subject, dee.subject);++ assert.strictEqual(person.consent_basis, 'request');++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 1);++ vector([0, 1, 0, 0]);++ const got = await match(ana, anaLibrary.id);++ assert.strictEqual(got.json().name, 'Sam');++ });++++ await check('muting a name keeps the match but marks its alerts held', async () => {++ const r = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people/${samPerson.id}`, { as: ana, method: 'PATCH', json: { muted: true } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().muted, true);++ vector([0, 1, 0, 0]);++ const got = await match(ana, anaLibrary.id);++ assert.strictEqual(got.json().match, true);++ assert.strictEqual(got.json().muted, true);++ });++++ await check('deleting a name deletes its template and its crops at once', async () => {++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 1);++ assert.ok(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [samPerson.id])) >= 1);++ const r = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people/${samPerson.id}`, { as: ana, method: 'DELETE' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().templates, 1);++ assert.ok(r.json().crops >= 1);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [samPerson.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_people WHERE id=$1', [samPerson.id])), 0);++ });++++ await check('open mode names a face freely, and a registry outage answers no', async () => {++ const open = (await create(ana, { camera_ids: [camera()], region: 'US-CA' })).json();++ vector([0, 0, 0, 1]);++ const named = (await enrol(ana, open.id, { name: 'Alex' })).json();++ assert.strictEqual(named.person.status, 'active');++ assert.strictEqual(named.person.consent_basis, null);++ assert.strictEqual(named.consent_request, null);++++ t.network.setDown(true);++ const strict = (await create(ana, { camera_ids: [camera()], region: 'US-WA' })).json();++ vector([0, 0, 0, 1]);++ const pending = (await enrol(ana, strict.id, { name: 'No Registry', subject: bo.subject })).json();++ assert.strictEqual(pending.person.status, 'pending_consent');++ assert.strictEqual(pending.person.has_template, false);++ t.network.setDown(false);++ });++++ await check('the owner cannot read or delete another owner\'s library', async () => {++ const other = (await create(cy, { camera_ids: [camera()], region: 'US-CA' })).json();++ assert.strictEqual((await t.get(`/api/v1/faces/libraries/${other.id}`, { as: ana })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/faces/libraries/${other.id}`, { as: ana, method: 'DELETE' })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/faces/libraries/${other.id}/enroll`, { as: ana, multipart: { fields: { name: 'Mine' }, image: png } })).status, 404);++ });++++ await check('deleting the library deletes every named face, template and crop', async () => {++ t.network.allowFace(bo.subject, true);++ vector([1, 0, 0, 0]);++ assert.strictEqual((await enrol(ana, anaLibrary.id, { name: 'Bo', subject: bo.subject })).status, 200);++ assert.ok(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_people WHERE library_id=$1', [anaLibrary.id])) >= 1);++ assert.ok(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE library_id=$1', [anaLibrary.id])) >= 1);++ const r = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}`, { as: ana, method: 'DELETE' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().deleted, true);++ assert.ok(r.json().templates >= 1);++ for (const table of ['face_library_people', 'face_library_crops', 'face_consent_requests', 'face_templates']) {++ assert.strictEqual(Number(await db.value(`SELECT COUNT(*)::int AS n FROM ${table} WHERE library_id=$1`, [anaLibrary.id])), 0, table);++ }++ });++++ await check('ADR-033 export and erasure cover the familiar-face tables', async () => {++ const lib = (await create(ana, { camera_ids: [camera()], region: 'US-CA' })).json();++ vector([1, 1, 0, 0]);++ assert.strictEqual((await enrol(ana, lib.id, { name: 'Mine' })).status, 200);++ const part = await t.ctx.accountData.exportPart(ana.subject);++ const files = part.files.map((f) => f.name);++ for (const f of ['face-libraries.json', 'face-library-people.json', 'face-library-crops.json']) assert.ok(files.includes(f), f);++ const erased = await t.ctx.accountData.erase([ana.subject]);++ assert.ok(erased.erased.face_libraries >= 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_libraries WHERE owner=$1', [ana.subject])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE owner=$1', [ana.subject])), 0);++ });++++ await check('logs never hold a template, a crop or a raw consent token', async () => {++ const logs = t.logs();++ assert.ok(!/1,0,0,0/.test(logs));++ assert.ok(!/image_ciphertext/.test(logs));++ });++ } finally {++ await t.close();++ }++ done();++})();@@@ -42,7 +42,7 @@ async function startNetwork() {const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });-- const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false, faceAllowed: new Set(), faceChecks: [], notifications: [] };let issuer = null;let nextUserId = 1;@@@ -119,7 +119,26 @@ async function startNetwork() {}if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });-- if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') {++ const auth = String(req.headers.authorization || '');++ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';++ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' }) : { ok: false };++ if (!v.ok || !Array.isArray(v.claims.cap) || !v.claims.cap.includes('network.notifications.push')) return problem(json, 403, 'token.forbidden', 'network.notifications.push is not granted');++ const body = JSON.parse(raw.toString('utf8') || '{}');++ st.notifications.push(body);++ return json(200, { ok: true });++ }++ if (url.pathname === '/internal/consent/check' && req.method === 'POST') {++ const auth = String(req.headers.authorization || '');++ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';++ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' }) : { ok: false };++ if (!v.ok || !Array.isArray(v.claims.cap) || !v.claims.cap.includes('network.consent.read')) return problem(json, 403, 'token.forbidden', 'network.consent.read is not granted');++ const body = JSON.parse(raw.toString('utf8') || '{}');++ st.faceChecks.push(body);++ const allowed = {};++ for (const subject of body.subjects || []) allowed[subject] = st.faceAllowed.has(subject);++ return json(200, { purpose: body.purpose, allowed, checked_at: new Date().toISOString(), feature: body.feature });++ }return json(404, { error: 'not found' });});@@@ -134,6 +153,7 @@ async function startNetwork() {},issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },setDown(v) { st.down = v; },++ allowFace(subject, on = true) { if (on) st.faceAllowed.add(subject); else st.faceAllowed.delete(subject); },close: srv.close,};}
@@ -68,6 +68,10 @@ DATA_DIR=
@@ -68,6 +68,10 @@ DATA_DIR=
VISION_FACE_KEY=
VISION_FACE_KEY=
# Comma-separated region codes; empty means library grouping is off everywhere.
# Comma-separated region codes; empty means library grouping is off everywhere.
VISION_FACE_LIBRARY_REGIONS=
VISION_FACE_LIBRARY_REGIONS=
+# F1 familiar faces: cameras in these regions take the named person's consent before a template exists.
+# An unknown region defaults to strict; everything else can name faces freely.
+#VISION_FACE_STRICT_REGIONS=US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN
+#VISION_FACE_CONSENT_REQUEST_DAYS=14
#VISION_FACE_MATCH_THRESHOLD=0.363
#VISION_FACE_MATCH_THRESHOLD=0.363
#VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN=30
#VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN=30
@@ -45,6 +45,17 @@ The four signed text versions are exposed by `GET /api/v1/faces/consents/text`.
@@ -45,6 +45,17 @@ The four signed text versions are exposed by `GET /api/v1/faces/consents/text`.
| `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/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/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. |
| `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. |
+| `GET/POST /api/v1/faces/libraries`, `GET/PATCH/DELETE /api/v1/faces/libraries/:id` | Person only; the owner's own familiar-face library and its camera ids/region. |
+| `POST /api/v1/faces/libraries/:id/enroll`, `/match`, `GET /people`, `PATCH/DELETE /people/:personId` | Person only; name a crop, match only this library, rename/mute or delete a name. Strict mode answers `pending_consent` with no template until consent. |
+| `POST /api/v1/faces/libraries/:id/people/:personId/request`, `/consent-check`, `POST /api/v1/faces/consent-requests/:token/accept` · `/deny` | Person only; the private consent link and the registry re-check. |
+
+### Familiar faces (F1)
+
+A camera owner turns on Familiar faces for a camera and names the people that camera sees. `POST /api/v1/faces/libraries` creates the owner's own library (`camera_ids`, `region`), `POST /api/v1/faces/libraries/:id/enroll` names a face from the crop the camera saw, and `POST /api/v1/faces/libraries/:id/match` compares only that library. `DELETE /api/v1/faces/libraries/:id` and `DELETE /api/v1/faces/libraries/:id/people/:personId` delete the templates and the encrypted crops at once.
+
+The region is the camera's own setting. `VISION_FACE_STRICT_REGIONS` (default `US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN`) puts a library in strict mode: the named person's `face.recognize` choice is checked in Network's consent registry, and until it allows the `library` feature the person is `pending_consent` with no template. The owner can re-issue a private link (`POST …/people/:personId/request`), the person accepts it at `POST /api/v1/faces/consent-requests/:token/accept` (or denies it), and `POST …/consent-check` re-reads the registry for pending people. A name can be muted (still matched, alerts held). A library whose region is outside the strict list (or explicitly `region_mode: "open"`) names a face freely.
+
+F1 adds `face_libraries`, `face_library_people`, `face_library_crops` and `face_consent_requests` in `migrations/0005_face_libraries.sql`, and `face_templates.library_id`. The templates and crops use the same AES-256-GCM key handling as every other face row; a crop is stored only so a strict-mode template can be made after consent, and a delete removes it. `VISION_FACE_CONSENT_REQUEST_DAYS` (default 14) sets the link lifetime.
`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.
`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.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"stage": "product",
"stage": "product",
"code": true,
"code": true,
"wave": "W21",
"wave": "W21",
- "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call; §5.8b adds faces with separate purpose-scoped consent; wave 1 adds the six network tools",
+ "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call; §5.8b adds faces with separate purpose-scoped consent; F1 (L24) adds a camera owner's own familiar-face libraries; wave 1 adds the six network tools",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)",
"port": "5130",
"port": "5130",
"domain": "openvibe.vision",
"domain": "openvibe.vision",
@@ -39,7 +39,9 @@
@@ -39,7 +39,9 @@
"vision.benchmarks.read"
"vision.benchmarks.read"
],
],
"outboundCapabilities": [
"outboundCapabilities": [
- "ai.run.create"
+ "ai.run.create",
+ "network.consent.read",
+ "network.notifications.push"
],
],
"capabilitiesRegistered": false,
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"serviceManifestRegistered": false,
@@ -53,6 +55,7 @@
@@ -53,6 +55,7 @@
"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",
"Local face detection and blur through YuNet without templates or tracking; detect/blur are open and rate-limited",
"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",
"Consent-scoped SFace matching: verify (1:1), self search in own Vision uploads, adult cast rosters and region-gated private library grouping",
+ "F1 familiar faces: a camera owner's own library (face_libraries, names, encrypted crops and templates); strict regions check Network's face.recognize registry before a template exists, a private link covers the rest, a match reads only that library, and delete removes templates and crops",
"Encrypted face templates, opt-in uploads and vectors (AES-256-GCM); immediate template/tag erasure on opt-out and a daily three-year retention job",
"Encrypted face templates, opt-in uploads and vectors (AES-256-GCM); immediate template/tag erasure on opt-out and a daily three-year retention job",
"Network tools: routed alt text, owner-scoped CLIP index/find, Tesseract-first receipts, product vectors, DETR watch and moments, each with a receipt",
"Network tools: routed alt text, owner-scoped CLIP index/find, Tesseract-first receipts, product vectors, DETR watch and moments, each with a receipt",
"MCP on /mcp with RFC 9728 protected-resource metadata, token challenges and route-equivalent capability checks",
"MCP on /mcp with RFC 9728 protected-resource metadata, token challenges and route-equivalent capability checks",
@@ -75,6 +78,7 @@
@@ -75,6 +78,7 @@
"deployed": false,
"deployed": false,
"features": [
"features": [
"ask a picture",
"ask a picture",
+ "familiar faces for a camera owner's own cameras",
"local-first OCR",
"local-first OCR",
"OpenVibe.AI routed answers",
"OpenVibe.AI routed answers",
"model compare with cost and latency",
"model compare with cost and latency",
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+-- phase: expand
+-- F1: familiar faces for a camera owner's own cameras (plan L24, 2026-10-31).
+--
+-- A library belongs to one person (the camera owner) and the cameras they name. Only that subject's templates are
+-- ever compared inside it: an unknown face is never looked up in another library, the platform or the web. The
+-- region mode comes from the camera's own setting; strict means the named person's consent is checked before a
+-- template exists. Templates stay AES-GCM ciphertext with the existing Vision key, and crops are ciphertext too.
+--
+-- The existing face_templates keeps its purpose rows. A familiar-face template is a row with feature='library'
+-- whose scope is the face_library_people id, so the (subject, feature, scope) key still holds one template per
+-- named person. library_id is the new column the plan asks for.
+
+CREATE TABLE face_libraries (
+ id text COLLATE "C" PRIMARY KEY, -- flb_ + ULID
+ owner text COLLATE "C" NOT NULL, -- usr_…
+ camera_ids jsonb NOT NULL DEFAULT '[]'::jsonb, -- Cam camera ids (cam_…); an owner may use one library for several
+ region text COLLATE "C" NOT NULL DEFAULT 'unknown',
+ region_mode text COLLATE "C" NOT NULL DEFAULT 'strict' CHECK (region_mode IN ('strict', 'open')),
+ threshold double precision NOT NULL DEFAULT 0.363,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT face_libraries_id CHECK (id ~ '^flb_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_libraries_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_libraries_cameras CHECK (jsonb_typeof(camera_ids) = 'array'),
+ CONSTRAINT face_libraries_region CHECK (length(region) BETWEEN 1 AND 40),
+ CONSTRAINT face_libraries_threshold CHECK (threshold >= 0 AND threshold <= 1)
+);
+CREATE INDEX face_libraries_by_owner ON face_libraries (owner, created_at DESC);
+
+-- One name in one library. A named person may be a Network subject (consent is checked in the registry) or only a
+-- name the owner gave, in which case the link request is the consent. Until consent, status is pending_consent and
+-- there is no template; the crop is kept encrypted only so the template can be made after the person accepts.
+CREATE TABLE face_library_people (
+ id text COLLATE "C" PRIMARY KEY, -- flp_ + ULID
+ library_id text COLLATE "C" NOT NULL REFERENCES face_libraries (id) ON DELETE CASCADE,
+ owner text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ subject text COLLATE "C", -- usr_… when the owner named an account
+ status text COLLATE "C" NOT NULL DEFAULT 'pending_consent'
+ CHECK (status IN ('active', 'pending_consent', 'denied')),
+ consent_basis text COLLATE "C" CHECK (consent_basis IN ('network', 'request')),
+ muted boolean NOT NULL DEFAULT false, -- a muted name is still matched; its alerts are held
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ last_matched_at timestamptz,
+ CONSTRAINT face_library_people_id CHECK (id ~ '^flp_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_library_people_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_library_people_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_library_people_name CHECK (length(name) BETWEEN 1 AND 80)
+);
+CREATE UNIQUE INDEX face_library_people_name ON face_library_people (library_id, lower(name)) WHERE status <> 'denied';
+CREATE INDEX face_library_people_library ON face_library_people (library_id, created_at DESC);
+CREATE INDEX face_library_people_pending ON face_library_people (status, subject) WHERE status = 'pending_consent';
+
+-- The crop the camera saw. Ciphertext only, exactly like face_media. Deleting the name deletes its crops at once.
+CREATE TABLE face_library_crops (
+ id text COLLATE "C" PRIMARY KEY, -- flc_ + ULID
+ library_id text COLLATE "C" NOT NULL REFERENCES face_libraries (id) ON DELETE CASCADE,
+ person_id text COLLATE "C" NOT NULL REFERENCES face_library_people (id) ON DELETE CASCADE,
+ owner text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL DEFAULT 'enrol' CHECK (kind IN ('enrol', 'pending')),
+ image_ciphertext text NOT NULL,
+ image_mime text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT face_library_crops_id CHECK (id ~ '^flc_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_library_crops_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_library_crops_mime CHECK (image_mime IN ('image/jpeg', 'image/png', 'image/webp'))
+);
+CREATE INDEX face_library_crops_by_person ON face_library_crops (person_id, created_at DESC);
+
+-- A consent request. The raw token is returned once to the owner (the link they send) and only its sha256 is stored.
+-- A person who already has a Network account also gets a Network notification through the registry.
+CREATE TABLE face_consent_requests (
+ id text COLLATE "C" PRIMARY KEY, -- fcr_ + ULID
+ library_id text COLLATE "C" NOT NULL REFERENCES face_libraries (id) ON DELETE CASCADE,
+ person_id text COLLATE "C" NOT NULL REFERENCES face_library_people (id) ON DELETE CASCADE,
+ owner text COLLATE "C" NOT NULL,
+ subject text COLLATE "C",
+ token_hash text COLLATE "C" NOT NULL UNIQUE,
+ status text COLLATE "C" NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'accepted', 'denied', 'expired')),
+ text_version text COLLATE "C" NOT NULL,
+ requested_at timestamptz NOT NULL DEFAULT now(),
+ decided_at timestamptz,
+ expires_at timestamptz NOT NULL,
+ CONSTRAINT face_consent_requests_id CHECK (id ~ '^fcr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_consent_requests_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_consent_requests_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT face_consent_requests_token CHECK (token_hash ~ '^[0-9a-f]{64}$'),
+ CONSTRAINT face_consent_requests_expiry CHECK (expires_at > requested_at)
+);
+CREATE INDEX face_consent_requests_pending ON face_consent_requests (person_id, requested_at DESC) WHERE status = 'pending';
+
+-- The library templates live in the existing table. Add its column and one-template-per-name index.
+ALTER TABLE face_templates ADD COLUMN library_id text COLLATE "C";
+ALTER TABLE face_templates
+ ADD CONSTRAINT face_templates_library_fk FOREIGN KEY (library_id) REFERENCES face_libraries (id) ON DELETE CASCADE;
+CREATE INDEX face_templates_by_library ON face_templates (library_id, last_used_at DESC) WHERE library_id IS NOT NULL;
+CREATE UNIQUE INDEX face_templates_one_per_person ON face_templates (library_id, scope) WHERE library_id IS NOT NULL;
@@ -42,6 +42,8 @@ const { createAi } = require('./vision/ai');
@@ -42,6 +42,8 @@ const { createAi } = require('./vision/ai');
const { createVision } = require('./vision/service');
const { createVision } = require('./vision/service');
const { createFacesModel } = require('./vision/faces-model');
const { createFacesModel } = require('./vision/faces-model');
const { createFaces } = require('./vision/faces');
const { createFaces } = require('./vision/faces');
+const { createFaceLibraries } = require('./vision/face-libraries');
+const { createConsentRegistry } = require('./vision/consent-registry');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPublisher } = require('./events/publisher');
const { createPublisher } = require('./events/publisher');
const { createVisionOutbox } = require('./events/outbox');
const { createVisionOutbox } = require('./events/outbox');
@@ -69,7 +71,10 @@ async function createApp(opts = {}) {
@@ -69,7 +71,10 @@ 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 facesModel = opts.facesModel || createFacesModel({ dir: config.faces.modelsDir });
+ const faces = createFaces({ s, config, model: facesModel });
+ const consentRegistry = createConsentRegistry({ config, fetchImpl, log });
+ const faceLibraries = createFaceLibraries({ s, config, model: facesModel, faces, network: consentRegistry, log });
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 });
@@ -84,7 +89,7 @@ async function createApp(opts = {}) {
@@ -84,7 +89,7 @@ async function createApp(opts = {}) {
// until OpenVibe.Events takes them, so a failed publish is not lost. The relay starts in server/index.js.
// until OpenVibe.Events takes them, so a failed publish is not lost. The relay starts in server/index.js.
const eventOutbox = opts.eventOutbox || createVisionOutbox({ s, config, log, fetchImpl });
const eventOutbox = opts.eventOutbox || createVisionOutbox({ s, config, log, fetchImpl });
const eventPublisher = opts.eventPublisher || createPublisher({ outbox: eventOutbox, log });
const eventPublisher = opts.eventPublisher || createPublisher({ outbox: eventOutbox, log });
- const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, models, networkTools, accountData, accountSend, eventOutbox, eventPublisher, log, fetchImpl };
+ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, faces, faceLibraries, consentRegistry, models, networkTools, accountData, accountSend, eventOutbox, eventPublisher, log, fetchImpl };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -104,6 +104,12 @@ function load(env = process.env) {
@@ -104,6 +104,12 @@ function load(env = process.env) {
modelsDir: modelsDir(env),
modelsDir: modelsDir(env),
key: env.VISION_FACE_KEY || '',
key: env.VISION_FACE_KEY || '',
allowedRegions: String(env.VISION_FACE_LIBRARY_REGIONS || '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),
allowedRegions: String(env.VISION_FACE_LIBRARY_REGIONS || '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),
+ // F1 familiar faces: the owner picks the camera's region; a region on this list takes a named person's
+ // consent first (Illinois, Texas, Washington, the EU and the UK). An unknown or unlisted region defaults
+ // to strict, never to the looser mode.
+ strictRegions: String(env.VISION_FACE_STRICT_REGIONS || 'US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN')
+ .split(',').map((x) => x.trim().toUpperCase()).filter(Boolean),
+ consentRequestDays: Math.min(90, Math.max(1, int(env.VISION_FACE_CONSENT_REQUEST_DAYS, 14))),
threshold: (() => {
threshold: (() => {
const raw = String(env.VISION_FACE_MATCH_THRESHOLD || '').trim();
const raw = String(env.VISION_FACE_MATCH_THRESHOLD || '').trim();
const n = raw ? Number(raw) : 0.363;
const n = raw ? Number(raw) : 0.363;
@@ -8,7 +8,7 @@ const { sameOrigin } = require('./principal');
@@ -8,7 +8,7 @@ const { sameOrigin } = require('./principal');
const { loadImage } = require('../vision/images');
const { loadImage } = require('../vision/images');
const { VisionError } = require('../vision/service');
const { VisionError } = require('../vision/service');
-function createFaceApi({ config, principal, faces, fetchImpl }) {
+function createFaceApi({ config, principal, faces, faceLibraries, fetchImpl }) {
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 } });
const imageField = (req, res, next) => upload.single('image')(req, res, next);
const imageField = (req, res, next) => upload.single('image')(req, res, next);
@@ -25,8 +25,8 @@ function createFaceApi({ config, principal, faces, fetchImpl }) {
@@ -25,8 +25,8 @@ function createFaceApi({ config, principal, faces, fetchImpl }) {
const b = req.body || {};
const b = req.body || {};
return loadImage({ file: req.file && req.file.buffer, image: b.image, imageUrl: b.image_url, config, fetchImpl });
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)); }
+ const run = (fn, status = 200) => async (req, res) => {
+ try { return res.status(status).json(await fn(req, res)); }
catch (err) {
catch (err) {
if (err instanceof VisionError) return problem(res, err.status, err.code, err.detail);
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);
if (err && /^image\./.test(err.code || '')) return problem(res, err.code === 'image.too_large' ? 413 : 400, `vision.${err.code.replace('.', '_')}`, err.message);
@@ -73,6 +73,26 @@ function createFaceApi({ config, principal, faces, fetchImpl }) {
@@ -73,6 +73,26 @@ function createFaceApi({ config, principal, faces, fetchImpl }) {
}
}
return next();
return next();
}, run(async (req) => faces.castMatch(req.body.channel, (await image(req)).buffer, req.body.media_ref)));
}, run(async (req) => faces.castMatch(req.body.channel, (await image(req)).buffer, req.body.media_ref)));
+ // ── F1: a camera owner's own familiar-face library ─────
+ // The camera's region decides the mode; strict mode names a person without a template until their consent is
+ // in Network's registry (or they accept the private link). A match reads only the library it names.
+ r.get('/libraries', person, run((req) => faceLibraries.list(req.principal.subject).then((libraries) => ({ count: libraries.length, libraries }))));
+ r.post('/libraries', person, run((req) => faceLibraries.create(req.principal.subject, req.body || {}), 201));
+ r.get('/libraries/:id', person, run((req) => faceLibraries.get(req.principal.subject, req.params.id)));
+ r.patch('/libraries/:id', person, run((req) => faceLibraries.patch(req.principal.subject, req.params.id, req.body || {})));
+ r.delete('/libraries/:id', person, run((req) => faceLibraries.remove(req.principal.subject, req.params.id)));
+ r.post('/libraries/:id/enroll', person, imageField, run(async (req) => {
+ const im = await image(req);
+ return faceLibraries.enroll(req.principal.subject, req.params.id, im.buffer, { ...(req.body || {}), image_mime: im.mime });
+ }));
+ r.post('/libraries/:id/match', person, imageField, run(async (req) => faceLibraries.match(req.principal.subject, req.params.id, (await image(req)).buffer)));
+ r.get('/libraries/:id/people', person, run((req) => faceLibraries.people(req.principal.subject, req.params.id)));
+ r.patch('/libraries/:id/people/:personId', person, run((req) => faceLibraries.patchPerson(req.principal.subject, req.params.id, req.params.personId, req.body || {})));
+ r.delete('/libraries/:id/people/:personId', person, run((req) => faceLibraries.removePerson(req.principal.subject, req.params.id, req.params.personId)));
+ r.post('/libraries/:id/people/:personId/request', person, run((req) => faceLibraries.requestAgain(req.principal.subject, req.params.id, req.params.personId)));
+ r.post('/libraries/:id/consent-check', person, run((req) => faceLibraries.checkPending(req.principal.subject, req.params.id)));
+ r.post('/consent-requests/:token/accept', person, run((req) => faceLibraries.acceptRequest(req.principal.subject, req.params.token, req.body || {})));
+ r.post('/consent-requests/:token/deny', person, run((req) => faceLibraries.denyRequest(req.principal.subject, req.params.token)));
r.post('/library/cluster', person, run((req) => faces.cluster(req.principal.subject, req.body?.region)));
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.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)));
r.post('/library/correct', person, run((req) => faces.correction(req.principal.subject, req.body?.action, req.body?.ids)));
@@ -11,10 +11,15 @@
@@ -11,10 +11,15 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
+ // F1 familiar-face libraries. Crops and requests go before the people and the library they hang under.
+ { table: 'face_library_crops', subject: 'owner', value: (usr) => usr, file: 'face-library-crops.json', erase: 'delete' },
+ { table: 'face_consent_requests', subject: 'owner', value: (usr) => usr, file: 'face-consent-requests.json', erase: 'delete' },
+ { table: 'face_templates', subject: 'subject', value: (usr) => usr, file: 'face-templates.json', erase: 'delete' },
+ { table: 'face_library_people', subject: 'owner', value: (usr) => usr, file: 'face-library-people.json', erase: 'delete' },
+ { table: 'face_libraries', subject: 'owner', value: (usr) => usr, file: 'face-libraries.json', erase: 'delete' },
{ table: 'face_tags', subject: 'subject', value: (usr) => usr, file: 'face-tags.json', erase: 'delete' },
{ 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_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_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: '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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * OpenVibe.Network's consent registry, as Vision sees it for F1 (L24; Contracts 0.256.0).
+ *
+ * allowed(subject) POST /internal/consent/check { purpose: 'face.recognize', feature: 'library' }
+ * notify(subject, n) POST /internal/notifications/push { subject, … } (a consent request)
+ *
+ * Both use Vision's own client-credentials token. A registry that cannot answer means "not allowed" and a request
+ * that cannot be sent is simply not sent: the owner still gets the private link and can send it instead. Neither
+ * call logs a subject, a face or a crop.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+function createConsentRegistry({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
+ const tokens = config.oauth.clientSecret
+ ? createServiceTokenClient({
+ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'network.consent.read network.notifications.push', fetch: fetchImpl,
+ })
+ : null;
+
+ async function call(path, body) {
+ if (!tokens) return null;
+ let token;
+ try { token = await tokens.getToken(); } catch { return null; }
+ try {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(Math.max(1500, Number(config.upstreamTimeoutMs) || 5000)),
+ });
+ if (!res.ok) return null;
+ return await res.json().catch(() => null);
+ } catch {
+ return null;
+ }
+ }
+
+ /** Strict mode: may this person's face be recognised in a camera owner's library? Missing answer = no. */
+ async function allowed(subject) {
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return false;
+ const data = await call('/internal/consent/check', { purpose: 'face.recognize', feature: 'library', subjects: [subject] });
+ return Boolean(data && data.allowed && data.allowed[subject] === true);
+ }
+
+ /** Send a consent request to a named person who has a Network account. Missing answer = not sent. */
+ async function notify(subject, { title, message, url } = {}) {
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return { sent: false };
+ const data = await call('/internal/notifications/push', {
+ subject,
+ type: 'FAMILIAR_FACES_CONSENT',
+ category: 'system',
+ priority: 'normal',
+ service: 'vision',
+ title: String(title || 'A camera owner named you').slice(0, 160),
+ message: String(message || 'Open their consent request to choose whether your face may be recognised.').slice(0, 1000),
+ url: url == null ? null : String(url).slice(0, 1000),
+ });
+ return { sent: Boolean(data && data.ok) };
+ }
+
+ return { allowed, notify, configured: Boolean(tokens) };
+}
+
+module.exports = { createConsentRegistry };
new file mode 100644
new file mode 100644
@@ -0,0 +1,465 @@
@@ -0,0 +1,465 @@
+'use strict';
+const crypto = require('crypto');
+const { VisionError } = require('./service');
+const { seal, open, keyOf } = require('./faces');
+
+const ID = {
+ library: /^flb_[0-9A-HJKMNP-TV-Z]{26}$/,
+ person: /^flp_[0-9A-HJKMNP-TV-Z]{26}$/,
+ camera: /^cam_[0-9A-HJKMNP-TV-Z]{26}$/,
+ subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/,
+};
+const MAX_CAMERAS = 50;
+const MAX_NAME = 80;
+const text = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
+const fail = (code, detail, status = 403) => { throw new VisionError(status, `faces.${code}`, detail); };
+const control = (v) => /[\u0000-\u001f\u007f]/.test(String(v == null ? '' : v));
+
+function normaliseRegion(raw) {
+ const r = text(raw).toUpperCase().replace(/\s+/g, '');
+ return r && r.length <= 40 ? r : 'unknown';
+}
+
+function wireLibrary(row, { people = null, threshold } = {}) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ camera_ids: Array.isArray(row.camera_ids) ? row.camera_ids : [],
+ region: row.region,
+ region_mode: row.region_mode,
+ threshold: threshold == null ? Number(row.threshold) : Number(threshold),
+ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
+ updated_at: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
+ people_count: row.people_count == null ? undefined : Number(row.people_count),
+ ...(people ? { people } : {}),
+ };
+}
+
+function wirePerson(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ library_id: row.library_id,
+ name: row.name,
+ subject: row.subject == null ? null : row.subject,
+ status: row.status,
+ consent_basis: row.consent_basis == null ? null : row.consent_basis,
+ muted: Boolean(row.muted),
+ has_template: Boolean(row.has_template),
+ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
+ updated_at: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
+ last_matched_at: row.last_matched_at == null ? null : (row.last_matched_at instanceof Date ? row.last_matched_at.toISOString() : String(row.last_matched_at)),
+ };
+}
+
+/**
+ * A camera owner's own familiar-face library (F1).
+ *
+ * There is exactly one template per named person, and a match reads only the library it was asked about. The
+ * consent rules are here, not in the page: the camera's region mode decides whether the named person's consent is
+ * checked in Network's registry (strict) or the owner names the face freely (open). Until consent the person has
+ * status pending_consent, an encrypted crop for the later enrolment, and no template at all.
+ */
+function createFaceLibraries({ s, config, model, faces, network, log = console }) {
+ const db = s.db;
+ const key = keyOf(config.faces.key);
+ const timestamp = () => s.iso();
+ const requireKey = () => { if (!key) fail('key_unavailable', 'Face encryption is not configured.', 503); };
+ const requireModel = () => {
+ const a = model.available();
+ if (!a.yunet || !a.sface) fail('model_unavailable', 'Face weights are not installed.', 503);
+ };
+ const strict = (region) => {
+ const r = normaliseRegion(region);
+ if (r === 'UNKNOWN') return true;
+ return (config.faces.strictRegions || []).some((x) => x === r || (x.endsWith('*') && r.startsWith(x.slice(0, -1))));
+ };
+ const regionMode = (region) => (strict(region) ? 'strict' : 'open');
+
+ function camerasOf(body) {
+ const raw = body && body.camera_ids !== undefined ? body.camera_ids : body && body.camera_id !== undefined ? [body.camera_id] : [];
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_CAMERAS) fail('invalid_cameras', `Name 1 to ${MAX_CAMERAS} cameras.`, 400);
+ const out = [...new Set(raw.map((x) => text(x)))];
+ if (out.some((x) => !ID.camera.test(x))) fail('invalid_cameras', 'A camera id is a Cam camera id (cam_…).', 400);
+ return out;
+ }
+
+ function cleanName(raw) {
+ const name = text(raw);
+ if (!name || name.length > MAX_NAME || control(raw)) fail('invalid_name', `A name is 1 to ${MAX_NAME} characters of plain text.`, 400);
+ return name;
+ }
+
+ async function validateSubject(raw) {
+ if (raw == null || raw === '') return null;
+ const subject = text(raw);
+ if (!ID.subject.test(subject)) fail('invalid_subject', 'subject must be a person\'s canonical usr_ id.', 400);
+ return subject;
+ }
+
+ async function libraryOf(owner, id) {
+ const row = await db.maybe('SELECT * FROM face_libraries WHERE id=$1 AND owner=$2', [String(id || ''), owner]);
+ if (!row) fail('not_found', 'No such face library.', 404);
+ return row;
+ }
+
+ async function personOf(owner, library, id) {
+ const row = await db.maybe(`SELECT p.*, (t.ciphertext IS NOT NULL) AS has_template
+ FROM face_library_people p LEFT JOIN face_templates t
+ ON t.library_id=p.library_id AND t.scope=p.id AND t.feature='library'
+ WHERE p.id=$1 AND p.library_id=$2 AND p.owner=$3`, [String(id || ''), library.id, owner]);
+ if (!row) fail('not_found', 'No such named face in this library.', 404);
+ return row;
+ }
+
+ async function peopleOf(library) {
+ const rows = await db.many(`SELECT p.*, (t.ciphertext IS NOT NULL) AS has_template
+ FROM face_library_people p LEFT JOIN face_templates t
+ ON t.library_id=p.library_id AND t.scope=p.id AND t.feature='library'
+ WHERE p.library_id=$1 ORDER BY p.created_at DESC, p.id DESC`, [library.id]);
+ return rows.map(wirePerson);
+ }
+
+ async function create(owner, body = {}) {
+ requireKey();
+ const cameraIds = camerasOf(body);
+ const region = normaliseRegion(body.region);
+ const mode = regionMode(region);
+ const existing = await db.many('SELECT id, camera_ids FROM face_libraries WHERE owner=$1', [owner]);
+ for (const row of existing) {
+ const ids = Array.isArray(row.camera_ids) ? row.camera_ids : [];
+ if (ids.some((id) => cameraIds.includes(id))) fail('camera_already_library', 'That camera already has a familiar-faces library.', 409);
+ }
+ const threshold = body.threshold == null ? config.faces.threshold : Number(body.threshold);
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) fail('invalid_threshold', 'threshold is from 0 to 1.', 400);
+ const id = s.newId('flb'), at = timestamp();
+ const row = await db.maybe(`INSERT INTO face_libraries (id, owner, camera_ids, region, region_mode, threshold, created_at, updated_at)
+ VALUES ($1,$2,$3::jsonb,$4,$5,$6,$7::timestamptz,$7::timestamptz) RETURNING *`,
+ [id, owner, JSON.stringify(cameraIds), region, mode, threshold, at]);
+ return wireLibrary(row, { people: [] });
+ }
+
+ async function list(owner) {
+ const rows = await db.many(`SELECT l.*, (SELECT COUNT(*)::int FROM face_library_people p WHERE p.library_id=l.id) AS people_count
+ FROM face_libraries l WHERE l.owner=$1 ORDER BY l.created_at DESC, l.id DESC`, [owner]);
+ return rows.map((r) => wireLibrary(r));
+ }
+
+ async function get(owner, id) {
+ const row = await libraryOf(owner, id);
+ return wireLibrary(row, { people: await peopleOf(row) });
+ }
+
+ async function patch(owner, id, body = {}) {
+ requireKey();
+ const row = await libraryOf(owner, id);
+ const sets = [];
+ const args = [row.id];
+ if (body.camera_ids !== undefined || body.camera_id !== undefined) {
+ const cameraIds = camerasOf(body);
+ const others = await db.many('SELECT id, camera_ids FROM face_libraries WHERE owner=$1 AND id<>$2', [owner, row.id]);
+ for (const other of others) {
+ const ids = Array.isArray(other.camera_ids) ? other.camera_ids : [];
+ if (ids.some((x) => cameraIds.includes(x))) fail('camera_already_library', 'That camera already has a familiar-faces library.', 409);
+ }
+ args.push(JSON.stringify(cameraIds)); sets.push(`camera_ids=$${args.length}::jsonb`);
+ }
+ if (body.region !== undefined) {
+ const region = normaliseRegion(body.region);
+ args.push(region); sets.push(`region=$${args.length}`);
+ args.push(regionMode(region)); sets.push(`region_mode=$${args.length}`);
+ }
+ if (body.threshold !== undefined) {
+ const n = Number(body.threshold);
+ if (!Number.isFinite(n) || n < 0 || n > 1) fail('invalid_threshold', 'threshold is from 0 to 1.', 400);
+ args.push(n); sets.push(`threshold=$${args.length}`);
+ }
+ if (!sets.length) fail('invalid', 'Send camera_ids, region or threshold.', 400);
+ args.push(timestamp());
+ const updated = await db.maybe(`UPDATE face_libraries SET ${sets.join(', ')}, updated_at=$${args.length}::timestamptz
+ WHERE id=$1 RETURNING *`, args);
+ return wireLibrary(updated, { people: await peopleOf(updated) });
+ }
+
+ async function remove(owner, id) {
+ requireKey();
+ const row = await libraryOf(owner, id);
+ const counts = await db.maybe(`SELECT
+ (SELECT COUNT(*)::int FROM face_library_people WHERE library_id=$1) AS people,
+ (SELECT COUNT(*)::int FROM face_library_crops WHERE library_id=$1) AS crops,
+ (SELECT COUNT(*)::int FROM face_templates WHERE library_id=$1) AS templates,
+ (SELECT COUNT(*)::int FROM face_consent_requests WHERE library_id=$1) AS requests`, [row.id]);
+ await db.exec('DELETE FROM face_libraries WHERE id=$1 AND owner=$2', [row.id, owner]);
+ return { deleted: true, id: row.id, people: Number(counts.people), crops: Number(counts.crops), templates: Number(counts.templates), requests: Number(counts.requests) };
+ }
+
+ async function saveCrop(library, person, buffer, mime, kind) {
+ await db.exec('DELETE FROM face_library_crops WHERE person_id=$1 AND library_id=$2', [person.id, library.id]);
+ await db.exec(`INSERT INTO face_library_crops (id, library_id, person_id, owner, kind, image_ciphertext, image_mime, created_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::timestamptz)`,
+ [s.newId('flc'), library.id, person.id, library.owner, kind, seal(key, buffer), mime, timestamp()]);
+ }
+
+ async function latestCrop(person) {
+ const row = await db.maybe('SELECT image_ciphertext FROM face_library_crops WHERE person_id=$1 ORDER BY created_at DESC, id DESC LIMIT 1', [person.id]);
+ if (!row) return null;
+ return open(key, row.image_ciphertext, true);
+ }
+
+ async function putTemplate(library, person, vector) {
+ const at = timestamp();
+ await db.exec(`INSERT INTO face_templates (subject, feature, scope, ciphertext, created_at, last_used_at, library_id)
+ VALUES ($1,'library',$2,$3,$4::timestamptz,$4::timestamptz,$5)
+ ON CONFLICT (subject, feature, scope) DO UPDATE SET ciphertext=EXCLUDED.ciphertext, last_used_at=EXCLUDED.last_used_at, library_id=EXCLUDED.library_id`,
+ [library.owner, person.id, seal(key, vector), at, library.id]);
+ }
+
+ async function embedCrop(buffer) {
+ const found = await model.detect(buffer);
+ if (found.length !== 1) fail('one_face_required', 'Naming a face needs exactly one face in the crop.', 422);
+ return await model.embed(buffer, found[0]);
+ }
+
+ async function requestLink(library, person) {
+ const token = crypto.randomBytes(24).toString('base64url');
+ const hash = crypto.createHash('sha256').update(token).digest('hex');
+ const at = timestamp();
+ const expires = new Date(s.now() + Number(config.faces.consentRequestDays) * 86_400_000).toISOString();
+ await s.tx(async (t) => {
+ await t.exec("UPDATE face_consent_requests SET status='expired', decided_at=$2::timestamptz WHERE person_id=$1 AND status='pending'", [person.id, at]);
+ await t.exec(`INSERT INTO face_consent_requests (id, library_id, person_id, owner, subject, token_hash, status, text_version, requested_at, expires_at)
+ VALUES ($1,$2,$3,$4,$5,$6,'pending',$7,$8::timestamptz,$9::timestamptz)`,
+ [s.newId('fcr'), library.id, person.id, library.owner, person.subject == null ? null : person.subject, hash, faces.TEXT.library.version, at, expires]);
+ });
+ const url = `${String(config.baseUrl).replace(/\/+$/, '')}/people/privacy?face_request=${encodeURIComponent(token)}`;
+ let sent = false;
+ if (person.subject) {
+ const got = await network.notify(person.subject, {
+ title: 'A camera owner asked to know your face',
+ message: 'Open OpenVibe.Vision to choose whether your face may be recognised in their camera. Until you do, the camera only sees a familiar visitor, and no template is stored.',
+ url,
+ });
+ sent = Boolean(got && got.sent);
+ }
+ return { token, url, expires_at: expires, sent, text_version: faces.TEXT.library.version };
+ }
+
+ /**
+ * Name a face from the crop the camera saw. In strict mode this returns pending_consent (no template) until the
+ * person's consent is in the registry or they accept the link. Open mode enrols at once.
+ */
+ async function enroll(owner, id, buffer, body = {}) {
+ requireKey(); requireModel();
+ const library = await libraryOf(owner, id);
+ const name = cleanName(body.name);
+ const subject = await validateSubject(body.subject);
+ const at = timestamp();
+ const vector = await embedCrop(buffer);
+ const mime = body.image_mime ? String(body.image_mime) : 'image/jpeg';
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(mime)) fail('invalid_image', 'The crop must be JPEG, PNG or WebP.', 400);
+
+ let person = await db.maybe('SELECT * FROM face_library_people WHERE library_id=$1 AND lower(name)=lower($2) AND status<>\'denied\'', [library.id, name]);
+ const consented = library.region_mode === 'open' || (subject ? await network.allowed(subject) : false);
+ const status = consented ? 'active' : 'pending_consent';
+ const basis = consented ? (library.region_mode === 'open' ? null : 'network') : null;
+
+ if (!person) {
+ person = await db.maybe(`INSERT INTO face_library_people (id, library_id, owner, name, subject, status, consent_basis, created_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::timestamptz,$8::timestamptz) RETURNING *`,
+ [s.newId('flp'), library.id, owner, name, subject, status, basis, at]);
+ } else {
+ person = await db.maybe(`UPDATE face_library_people SET name=$2, subject=COALESCE($3,subject), status=$4, consent_basis=$5, updated_at=$6::timestamptz
+ WHERE id=$1 RETURNING *`, [person.id, name, subject, status, basis, at]);
+ }
+ await saveCrop(library, person, buffer, mime, status === 'active' ? 'enrol' : 'pending');
+
+ if (status === 'active') {
+ await putTemplate(library, person, vector);
+ await db.exec("UPDATE face_consent_requests SET status='expired', decided_at=$2::timestamptz WHERE person_id=$1 AND status='pending'", [person.id, at]);
+ return { person: wirePerson({ ...person, has_template: true }), consent_request: null, enrolled: true };
+ }
+ // Pending consent means no template at all, including an earlier one for this name.
+ await db.exec("DELETE FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);
+ const request = await requestLink(library, person);
+ return { person: wirePerson({ ...person, has_template: false }), consent_request: request, enrolled: false };
+ }
+
+ /** Is this named person's consent still there? Strict mode re-checks the registry on the next match; the
+ * private-link acceptance is a local face_consents row, and a revoked one no longer counts. */
+ async function consentActive(library, person) {
+ if (library.region_mode !== 'strict') return true;
+ if (!person.subject) return false;
+ if (person.consent_basis === 'request') {
+ const row = await db.maybe("SELECT 1 AS ok FROM face_consents WHERE subject=$1 AND feature='library' AND revoked_at IS NULL LIMIT 1", [person.subject]);
+ return Boolean(row);
+ }
+ return await network.allowed(person.subject);
+ }
+
+ async function deactivatePending(library, personId) {
+ await s.tx(async (t) => {
+ await t.exec("DELETE FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, personId]);
+ await t.exec("UPDATE face_library_people SET status='pending_consent', consent_basis=NULL, updated_at=$2::timestamptz WHERE id=$1 AND status='active'", [personId, timestamp()]);
+ });
+ }
+
+ async function match(owner, id, buffer) {
+ requireKey(); requireModel();
+ const library = await libraryOf(owner, id);
+ const found = await model.detect(buffer);
+ if (found.length !== 1) fail('one_face_required', 'A match needs exactly one face in the crop.', 422);
+ const probe = await model.embed(buffer, found[0]);
+ // Only this library's templates: the owner and library_id are both in the query, so another person's
+ // library is never read (and an empty library answers unknown without a lookup anywhere else).
+ const rows = await db.many(`SELECT p.id, p.name, p.muted, p.subject, p.consent_basis, t.ciphertext
+ FROM face_templates t JOIN face_library_people p
+ ON p.library_id=t.library_id AND p.id=t.scope AND p.status='active'
+ WHERE t.subject=$1 AND t.library_id=$2 AND t.feature='library'`, [owner, library.id]);
+ const ranked = rows.map((row) => ({ ...row, score: model.cosine(open(key, row.ciphertext), probe) })).sort((a, b) => b.score - a.score);
+ const threshold = Number(library.threshold);
+ let best = null;
+ for (const row of ranked.slice(0, 10)) {
+ if (!(await consentActive(library, row))) {
+ // The person's consent is gone: the template goes at once and the next check answers unknown.
+ await deactivatePending(library, row.id);
+ continue;
+ }
+ if (row.score >= threshold) { best = row; break; }
+ if (!best) best = { ...row, score: row.score };
+ }
+ if (!best || best.score < threshold) {
+ return { library_id: library.id, match: false, label: 'unknown person', name: null, person_id: null, muted: false, score: best ? best.score : null, threshold };
+ }
+ await db.exec('UPDATE face_library_people SET last_matched_at=$2::timestamptz WHERE id=$1', [best.id, timestamp()]);
+ return { library_id: library.id, match: true, label: best.name, name: best.name, person_id: best.id, muted: best.muted, score: best.score, threshold };
+ }
+
+ async function people(owner, id) {
+ const library = await libraryOf(owner, id);
+ return { library: wireLibrary(library), people: await peopleOf(library) };
+ }
+
+ async function patchPerson(owner, id, personId, body = {}) {
+ const library = await libraryOf(owner, id);
+ const person = await personOf(owner, library, personId);
+ const sets = [];
+ const args = [person.id];
+ if (body.name !== undefined) {
+ const name = cleanName(body.name);
+ const clash = await db.maybe(`SELECT id FROM face_library_people WHERE library_id=$1 AND lower(name)=lower($2) AND status<>'denied' AND id<>$3`,
+ [library.id, name, person.id]);
+ if (clash) fail('name_taken', 'That name is already in this library.', 409);
+ args.push(name); sets.push(`name=$${args.length}`);
+ }
+ if (body.muted !== undefined) {
+ args.push(Boolean(body.muted)); sets.push(`muted=$${args.length}`);
+ }
+ if (!sets.length) fail('invalid', 'Send a name or muted.', 400);
+ args.push(timestamp());
+ const updated = await db.maybe(`UPDATE face_library_people SET ${sets.join(', ')}, updated_at=$${args.length}::timestamptz WHERE id=$1 RETURNING *`, args);
+ const has = await db.maybe("SELECT 1 AS ok FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);
+ return wirePerson({ ...updated, has_template: Boolean(has) });
+ }
+
+ async function removePerson(owner, id, personId) {
+ requireKey();
+ const library = await libraryOf(owner, id);
+ const person = await personOf(owner, library, personId);
+ const templates = await db.maybe("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);
+ const crops = await db.maybe('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [person.id]);
+ const requests = await db.maybe('SELECT COUNT(*)::int AS n FROM face_consent_requests WHERE person_id=$1', [person.id]);
+ await s.tx(async (t) => {
+ await t.exec("DELETE FROM face_templates WHERE library_id=$1 AND scope=$2 AND feature='library'", [library.id, person.id]);
+ await t.exec('DELETE FROM face_consent_requests WHERE person_id=$1', [person.id]);
+ await t.exec('DELETE FROM face_library_crops WHERE person_id=$1', [person.id]);
+ await t.exec('DELETE FROM face_library_people WHERE id=$1', [person.id]);
+ });
+ return { deleted: true, id: person.id, name: person.name, templates: Number(templates.n), crops: Number(crops.n), requests: Number(requests.n) };
+ }
+
+ /** Re-check the registry for pending people. The camera page may call this when it opens; a match never waits on it. */
+ async function checkPending(owner, id) {
+ const library = await libraryOf(owner, id);
+ const pending = await db.many("SELECT * FROM face_library_people WHERE library_id=$1 AND owner=$2 AND status='pending_consent' AND subject IS NOT NULL ORDER BY created_at LIMIT 50", [library.id, owner]);
+ const activated = [];
+ for (const person of pending) {
+ if (!(await network.allowed(person.subject))) continue;
+ const crop = await latestCrop(person);
+ if (!crop) continue;
+ let vector;
+ try { vector = await embedCrop(crop); } catch { continue; }
+ await s.tx(async (t) => {
+ await t.exec("UPDATE face_library_people SET status='active', consent_basis='network', updated_at=$2::timestamptz WHERE id=$1 AND status='pending_consent'", [person.id, timestamp()]);
+ await t.exec("UPDATE face_consent_requests SET status='accepted', decided_at=$2::timestamptz WHERE person_id=$1 AND status='pending'", [person.id, timestamp()]);
+ });
+ await putTemplate(library, person, vector);
+ activated.push(wirePerson({ ...person, status: 'active', consent_basis: 'network', has_template: true }));
+ }
+ return { checked: pending.length, activated };
+ }
+
+ async function requestAgain(owner, id, personId) {
+ const library = await libraryOf(owner, id);
+ const person = await personOf(owner, library, personId);
+ if (person.status !== 'pending_consent') fail('already_decided', 'That name is not waiting for consent.', 409);
+ return await requestLink(library, person);
+ }
+
+ async function acceptRequest(actor, token, body = {}) {
+ requireKey(); requireModel();
+ const raw = text(token);
+ if (!/^[A-Za-z0-9_-]{20,80}$/.test(raw)) fail('request_not_found', 'No such consent request.', 404);
+ const hash = crypto.createHash('sha256').update(raw).digest('hex');
+ const row = await db.maybe(`SELECT r.*, l.owner, l.region, p.name AS person_name, p.subject AS person_subject
+ FROM face_consent_requests r JOIN face_libraries l ON l.id=r.library_id
+ JOIN face_library_people p ON p.id=r.person_id
+ WHERE r.token_hash=$1`, [hash]);
+ if (!row || row.status !== 'pending') fail('request_not_found', 'No such open consent request.', 404);
+ if (Date.parse(row.expires_at) <= s.now()) {
+ await db.exec("UPDATE face_consent_requests SET status='expired', decided_at=$2::timestamptz WHERE id=$1", [row.id, timestamp()]);
+ fail('request_expired', 'That consent request has expired; ask the camera owner for a new link.', 410);
+ }
+ if (row.subject && row.subject !== actor) fail('request_not_yours', 'This consent request names another person.', 403);
+ const signedName = text(body.signed_name);
+ if (!signedName || signedName.length > 160 || control(body.signed_name)) fail('signature_required', 'Sign the current face-library consent text.', 400);
+ if (body.text_version !== faces.TEXT.library.version || body.text_version !== row.text_version) fail('signature_required', 'Read the current consent text and sign it.', 400);
+ const crop = await latestCrop({ id: row.person_id });
+ if (!crop) fail('crop_missing', 'The camera crop is no longer available; ask the owner to name you again.', 409);
+ const vector = await embedCrop(crop);
+ const at = timestamp();
+ await s.tx(async (t) => {
+ await t.exec(`UPDATE face_consent_requests SET status='accepted', subject=COALESCE(subject,$2), decided_at=$3::timestamptz WHERE id=$1`, [row.id, actor, at]);
+ await t.exec(`UPDATE face_library_people SET subject=COALESCE(subject,$2), status='active', consent_basis='request', updated_at=$3::timestamptz WHERE id=$1`, [row.person_id, actor, at]);
+ const active = await t.maybe("SELECT id FROM face_consents WHERE subject=$1 AND feature='library' AND revoked_at IS NULL LIMIT 1", [actor]);
+ if (!active) {
+ await t.exec(`INSERT INTO face_consents (id, subject, feature, scope, text_version, signature, granted_at, last_used_at)
+ VALUES ($1,$2,'library','',$3,$4,$5::timestamptz,$5::timestamptz)`,
+ [s.newId('fco'), actor, faces.TEXT.library.version, signedName, at]);
+ }
+ });
+ const library = await db.maybe('SELECT * FROM face_libraries WHERE id=$1', [row.library_id]);
+ const person = await db.maybe('SELECT * FROM face_library_people WHERE id=$1', [row.person_id]);
+ await putTemplate(library, person, vector);
+ return { accepted: true, library_id: row.library_id, person: wirePerson({ ...person, has_template: true }), text_version: faces.TEXT.library.version };
+ }
+
+ async function denyRequest(actor, token) {
+ const raw = text(token);
+ if (!/^[A-Za-z0-9_-]{20,80}$/.test(raw)) fail('request_not_found', 'No such consent request.', 404);
+ const hash = crypto.createHash('sha256').update(raw).digest('hex');
+ const row = await db.maybe('SELECT * FROM face_consent_requests WHERE token_hash=$1', [hash]);
+ if (!row || row.status !== 'pending') fail('request_not_found', 'No such open consent request.', 404);
+ if (row.subject && row.subject !== actor) fail('request_not_yours', 'This consent request names another person.', 403);
+ const at = timestamp();
+ await s.tx(async (t) => {
+ await t.exec("UPDATE face_consent_requests SET status='denied', subject=COALESCE(subject,$2), decided_at=$3::timestamptz WHERE id=$1", [row.id, actor, at]);
+ await t.exec("UPDATE face_library_people SET status='denied', updated_at=$2::timestamptz WHERE id=$1", [row.person_id, at]);
+ await t.exec('DELETE FROM face_library_crops WHERE person_id=$1', [row.person_id]);
+ });
+ return { denied: true, library_id: row.library_id };
+ }
+
+ return { text: faces.TEXT.library, regionMode, strict, create, list, get, patch, remove, enroll, match, people, patchPerson, removePerson, checkPending, requestAgain, acceptRequest, denyRequest, _libraryOf: libraryOf };
+}
+
+module.exports = { createFaceLibraries, ID, normaliseRegion, wireLibrary, wirePerson };
new file mode 100644
new file mode 100644
@@ -0,0 +1,253 @@
@@ -0,0 +1,253 @@
+'use strict';
+/**
+ * F1: familiar faces for a camera owner's own cameras.
+ *
+ * The rules the plan makes hard: a match reads only the library it names, an unknown face stays unknown, strict mode
+ * stores no template until the named person's consent is in the registry (or they accept the link), and deleting a
+ * name or a camera's library removes its templates and crops. The model is a fixture: no weights, no inference.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { cosine } = require('../server/vision/faces-model');
+
+const KEY = Buffer.alloc(32, 9).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]] };
+const camera = () => `cam_${ids.ulid()}`;
+
+function fakeModel() {
+ const state = { vector: [1, 0, 0, 0], detectCalls: 0 };
+ return {
+ state,
+ available: () => ({ yunet: true, sface: true }),
+ detect: async () => { state.detectCalls++; return [{ ...FACE, landmarks: FACE.landmarks.map((p) => p.slice()) }]; },
+ embed: async () => state.vector.slice(),
+ blur: async () => Buffer.from('blurred'),
+ cosine,
+ };
+}
+
+(async () => {
+ const model = fakeModel();
+ const t = await boot({
+ env: {
+ VISION_FACE_KEY: KEY,
+ VISION_FACE_LIBRARY_REGIONS: 'US-IL,US-TX,US-WA',
+ VISION_FACE_STRICT_REGIONS: 'US-IL,US-TX,US-WA,EU,GB,UNKNOWN',
+ VISION_FACE_MATCH_THRESHOLD: '0.6',
+ VISION_FACE_CONSENT_REQUEST_DAYS: '7',
+ },
+ 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 png = await t.textImage('A FACE');
+ const db = t.ctx.s.db;
+ const vector = (v) => { model.state.vector = v; };
+ const create = (as, body) => t.get('/api/v1/faces/libraries', { as, json: body });
+ const enrol = (as, libraryId, fields) => t.get(`/api/v1/faces/libraries/${libraryId}/enroll`, { as, multipart: { fields, image: png } });
+ const match = (as, libraryId) => t.get(`/api/v1/faces/libraries/${libraryId}/match`, { as, multipart: { image: png } });
+ const rows = async (sql, args) => await db.many(sql, args);
+
+ try {
+ let anaLibrary = null;
+ let boPerson = null;
+ let samPerson = null;
+
+ await check('a camera owner gets a library with the camera region and strict mode', async () => {
+ const r = await create(ana, { camera_ids: [camera(), camera()], region: 'US-IL' });
+ assert.strictEqual(r.status, 201, r.text);
+ anaLibrary = r.json();
+ assert.match(anaLibrary.id, /^flb_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(anaLibrary.region, 'US-IL');
+ assert.strictEqual(anaLibrary.region_mode, 'strict');
+ assert.strictEqual(anaLibrary.camera_ids.length, 2);
+ assert.strictEqual((await create(ana, { camera_ids: [camera()], region: 'US-CA' })).json().region_mode, 'open');
+ assert.strictEqual((await create(ana, { camera_ids: ['not-a-camera'], region: 'US-IL' })).status, 400);
+ assert.strictEqual((await create(ana, { camera_ids: anaLibrary.camera_ids, region: 'US-IL' })).status, 409);
+ });
+
+ await check('strict mode names a face with the registry consent and stores an encrypted template', async () => {
+ t.network.allowFace(bo.subject, true);
+ vector([1, 0, 0, 0]);
+ const r = await enrol(ana, anaLibrary.id, { name: 'Bo', subject: bo.subject });
+ assert.strictEqual(r.status, 200, r.text);
+ boPerson = r.json().person;
+ assert.strictEqual(boPerson.status, 'active');
+ assert.strictEqual(boPerson.consent_basis, 'network');
+ assert.strictEqual(boPerson.has_template, true);
+ const rows = await db.many("SELECT ciphertext, library_id FROM face_templates WHERE subject=$1 AND feature='library' AND scope=$2", [ana.subject, boPerson.id]);
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].library_id, anaLibrary.id);
+ assert.ok(!rows[0].ciphertext.includes('1,0,0,0'), 'the vector is ciphertext');
+ assert.ok(t.network.state.faceChecks.some((c) => c.purpose === 'face.recognize' && c.feature === 'library' && c.subjects.includes(bo.subject)));
+ });
+
+ await check('strict mode blocks the template for a named person without consent and sends a request', async () => {
+ vector([0, 1, 0, 0]);
+ const r = await enrol(ana, anaLibrary.id, { name: 'Sam' });
+ assert.strictEqual(r.status, 200, r.text);
+ samPerson = r.json().person;
+ assert.strictEqual(samPerson.status, 'pending_consent');
+ assert.strictEqual(samPerson.has_template, false);
+ assert.ok(r.json().consent_request.url.includes('face_request='));
+ assert.strictEqual(r.json().consent_request.sent, false);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [samPerson.id])), 1);
+ });
+
+ await check('a match reads only its own library and returns the top named face', async () => {
+ vector([1, 0, 0, 0]);
+ const got = await match(ana, anaLibrary.id);
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.json().match, true);
+ assert.strictEqual(got.json().name, 'Bo');
+ assert.strictEqual(got.json().person_id, boPerson.id);
+ assert.ok(got.json().score >= 0.6);
+ assert.strictEqual(got.json().label, 'Bo');
+ });
+
+ await check('revoking registry consent on the next match drops the template and answers unknown', async () => {
+ t.network.allowFace(bo.subject, false);
+ vector([1, 0, 0, 0]);
+ const got = await match(ana, anaLibrary.id);
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.json().match, false);
+ assert.strictEqual(got.json().label, 'unknown person');
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, boPerson.id])), 0);
+ const person = await db.maybe('SELECT status FROM face_library_people WHERE id=$1', [boPerson.id]);
+ assert.strictEqual(person.status, 'pending_consent');
+ t.network.allowFace(bo.subject, true);
+ });
+
+ await check('an unknown face stays unknown and never consults another owner library', async () => {
+ const otherLibrary = (await create(cy, { camera_ids: [camera()], region: 'US-CA' })).json();
+ vector([1, 0, 0, 0]);
+ const deePerson = (await enrol(cy, otherLibrary.id, { name: 'Dee' })).json().person;
+ assert.strictEqual(deePerson.status, 'active');
+ vector([0, 0, 1, 0]);
+ const got = await match(ana, anaLibrary.id);
+ assert.strictEqual(got.status, 200, got.text);
+ assert.strictEqual(got.json().match, false);
+ assert.strictEqual(got.json().name, null);
+ assert.strictEqual(got.json().label, 'unknown person');
+ vector([1, 0, 0, 0]);
+ const cyGot = await match(cy, otherLibrary.id);
+ assert.strictEqual(cyGot.json().name, 'Dee');
+ assert.notStrictEqual(cyGot.json().person_id, boPerson.id);
+ // The unknown answer named nobody from the other owner's library.
+ assert.ok(!JSON.stringify(got.json()).includes('Dee'));
+ });
+
+ await check('the private consent link turns a pending name into a template after the person accepts', async () => {
+ const people = (await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people`, { as: ana })).json();
+ assert.ok(people.people.some((p) => p.id === samPerson.id && !p.has_template));
+ // The raw token is not stored; the owner re-issues a link when needed and that request is the one accepted.
+ const again = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people/${samPerson.id}/request`, { as: ana, method: 'POST' });
+ assert.strictEqual(again.status, 200, again.text);
+ const link = again.json().url;
+ const raw = new URL(link).searchParams.get('face_request');
+ assert.ok(raw);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_consent_requests WHERE person_id=$1 AND status='pending'", [samPerson.id])), 1);
+ vector([0, 1, 0, 0]);
+ const accepted = await t.get(`/api/v1/faces/consent-requests/${raw}/accept`, {
+ as: dee, json: { signed_name: 'Dee', text_version: t.ctx.faces.TEXT.library.version },
+ });
+ assert.strictEqual(accepted.status, 200, accepted.text);
+ assert.strictEqual(accepted.json().accepted, true);
+ const person = await db.maybe('SELECT * FROM face_library_people WHERE id=$1', [samPerson.id]);
+ assert.strictEqual(person.status, 'active');
+ assert.strictEqual(person.subject, dee.subject);
+ assert.strictEqual(person.consent_basis, 'request');
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 1);
+ vector([0, 1, 0, 0]);
+ const got = await match(ana, anaLibrary.id);
+ assert.strictEqual(got.json().name, 'Sam');
+ });
+
+ await check('muting a name keeps the match but marks its alerts held', async () => {
+ const r = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people/${samPerson.id}`, { as: ana, method: 'PATCH', json: { muted: true } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().muted, true);
+ vector([0, 1, 0, 0]);
+ const got = await match(ana, anaLibrary.id);
+ assert.strictEqual(got.json().match, true);
+ assert.strictEqual(got.json().muted, true);
+ });
+
+ await check('deleting a name deletes its template and its crops at once', async () => {
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 1);
+ assert.ok(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [samPerson.id])) >= 1);
+ const r = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}/people/${samPerson.id}`, { as: ana, method: 'DELETE' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().templates, 1);
+ assert.ok(r.json().crops >= 1);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM face_templates WHERE library_id=$1 AND scope=$2", [anaLibrary.id, samPerson.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE person_id=$1', [samPerson.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_people WHERE id=$1', [samPerson.id])), 0);
+ });
+
+ await check('open mode names a face freely, and a registry outage answers no', async () => {
+ const open = (await create(ana, { camera_ids: [camera()], region: 'US-CA' })).json();
+ vector([0, 0, 0, 1]);
+ const named = (await enrol(ana, open.id, { name: 'Alex' })).json();
+ assert.strictEqual(named.person.status, 'active');
+ assert.strictEqual(named.person.consent_basis, null);
+ assert.strictEqual(named.consent_request, null);
+
+ t.network.setDown(true);
+ const strict = (await create(ana, { camera_ids: [camera()], region: 'US-WA' })).json();
+ vector([0, 0, 0, 1]);
+ const pending = (await enrol(ana, strict.id, { name: 'No Registry', subject: bo.subject })).json();
+ assert.strictEqual(pending.person.status, 'pending_consent');
+ assert.strictEqual(pending.person.has_template, false);
+ t.network.setDown(false);
+ });
+
+ await check('the owner cannot read or delete another owner\'s library', async () => {
+ const other = (await create(cy, { camera_ids: [camera()], region: 'US-CA' })).json();
+ assert.strictEqual((await t.get(`/api/v1/faces/libraries/${other.id}`, { as: ana })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/faces/libraries/${other.id}`, { as: ana, method: 'DELETE' })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/faces/libraries/${other.id}/enroll`, { as: ana, multipart: { fields: { name: 'Mine' }, image: png } })).status, 404);
+ });
+
+ await check('deleting the library deletes every named face, template and crop', async () => {
+ t.network.allowFace(bo.subject, true);
+ vector([1, 0, 0, 0]);
+ assert.strictEqual((await enrol(ana, anaLibrary.id, { name: 'Bo', subject: bo.subject })).status, 200);
+ assert.ok(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_people WHERE library_id=$1', [anaLibrary.id])) >= 1);
+ assert.ok(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE library_id=$1', [anaLibrary.id])) >= 1);
+ const r = await t.get(`/api/v1/faces/libraries/${anaLibrary.id}`, { as: ana, method: 'DELETE' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().deleted, true);
+ assert.ok(r.json().templates >= 1);
+ for (const table of ['face_library_people', 'face_library_crops', 'face_consent_requests', 'face_templates']) {
+ assert.strictEqual(Number(await db.value(`SELECT COUNT(*)::int AS n FROM ${table} WHERE library_id=$1`, [anaLibrary.id])), 0, table);
+ }
+ });
+
+ await check('ADR-033 export and erasure cover the familiar-face tables', async () => {
+ const lib = (await create(ana, { camera_ids: [camera()], region: 'US-CA' })).json();
+ vector([1, 1, 0, 0]);
+ assert.strictEqual((await enrol(ana, lib.id, { name: 'Mine' })).status, 200);
+ const part = await t.ctx.accountData.exportPart(ana.subject);
+ const files = part.files.map((f) => f.name);
+ for (const f of ['face-libraries.json', 'face-library-people.json', 'face-library-crops.json']) assert.ok(files.includes(f), f);
+ const erased = await t.ctx.accountData.erase([ana.subject]);
+ assert.ok(erased.erased.face_libraries >= 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_libraries WHERE owner=$1', [ana.subject])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM face_library_crops WHERE owner=$1', [ana.subject])), 0);
+ });
+
+ await check('logs never hold a template, a crop or a raw consent token', async () => {
+ const logs = t.logs();
+ assert.ok(!/1,0,0,0/.test(logs));
+ assert.ok(!/image_ciphertext/.test(logs));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
@@ -42,7 +42,7 @@ async function startNetwork() {
@@ -42,7 +42,7 @@ async function startNetwork() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
- const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false, faceAllowed: new Set(), faceChecks: [], notifications: [] };
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -119,7 +119,26 @@ async function startNetwork() {
@@ -119,7 +119,26 @@ async function startNetwork() {
}
}
if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
- if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') {
+ const auth = String(req.headers.authorization || '');
+ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
+ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' }) : { ok: false };
+ if (!v.ok || !Array.isArray(v.claims.cap) || !v.claims.cap.includes('network.notifications.push')) return problem(json, 403, 'token.forbidden', 'network.notifications.push is not granted');
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ st.notifications.push(body);
+ return json(200, { ok: true });
+ }
+ if (url.pathname === '/internal/consent/check' && req.method === 'POST') {
+ const auth = String(req.headers.authorization || '');
+ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
+ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' }) : { ok: false };
+ if (!v.ok || !Array.isArray(v.claims.cap) || !v.claims.cap.includes('network.consent.read')) return problem(json, 403, 'token.forbidden', 'network.consent.read is not granted');
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ st.faceChecks.push(body);
+ const allowed = {};
+ for (const subject of body.subjects || []) allowed[subject] = st.faceAllowed.has(subject);
+ return json(200, { purpose: body.purpose, allowed, checked_at: new Date().toISOString(), feature: body.feature });
+ }
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
@@ -134,6 +153,7 @@ async function startNetwork() {
@@ -134,6 +153,7 @@ async function startNetwork() {
},
},
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
setDown(v) { st.down = v; },
setDown(v) { st.down = v; },
+ allowFace(subject, on = true) { if (on) st.faceAllowed.add(subject); else st.faceAllowed.delete(subject); },
close: srv.close,
close: srv.close,
};
};
}
}