openvibers/openvibe.vision Commits

F1: familiar faces for a camera owner’s own cameras (#28)

OP OpenVibers committed 11 days ago · 45549c18f8 · 12 files, +969 −10

* F1: familiar faces for the camera owner's own cameras

A camera owner gets an encrypted face library scoped to their own cameras. Strict regions check Network's face.recognize registry before a template exists; a private link covers the rest, an unknown face stays unknown, and match reads only the library it names. Deleting a name or library removes templates and crops.

* Merge remote-tracking branch 'origin/main' into refresh-28

Changes

ddiff --git a/.env.example b/.env.example
iindex 7191a34..6323fdc 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 4f49ccf..c0d1ac8 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 367d28c..b8d0c19 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0005_face_libraries.sql b/migrations/0005_face_libraries.sql
nnew file mode 100644
iindex 0000000..4924d10
---- /dev/null
++++ b/migrations/0005_face_libraries.sql
@@@ -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;
ddiff --git a/server/app.js b/server/app.js
iindex 9c6e1bd..2b3a813 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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');
ddiff --git a/server/config.js b/server/config.js
iindex f60ffcd..f4e64d2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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;
ddiff --git a/server/http/faces-api.js b/server/http/faces-api.js
iindex 1c4cd77..6592d78 100644
---- a/server/http/faces-api.js
++++ b/server/http/faces-api.js
@@@ -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)));
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex df372e5..7810f54 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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.
ddiff --git a/server/vision/consent-registry.js b/server/vision/consent-registry.js
nnew file mode 100644
iindex 0000000..ee314e6
---- /dev/null
++++ b/server/vision/consent-registry.js
@@@ -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 };
ddiff --git a/server/vision/face-libraries.js b/server/vision/face-libraries.js
nnew file mode 100644
iindex 0000000..e701cb7
---- /dev/null
++++ b/server/vision/face-libraries.js
@@@ -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 };
ddiff --git a/test/face-libraries.test.js b/test/face-libraries.test.js
nnew file mode 100644
iindex 0000000..9e75b61
---- /dev/null
++++ b/test/face-libraries.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 5966e70..630a781 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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,
};
}