openvibers/openvibe.cam Commits

F1: familiar faces, event timeline and police export (#22)

OP OpenVibers committed 11 days ago · a928e97bec · 14 files, +1119 −13

* F1: familiar faces, the event timeline and the police export

A camera's Familiar faces setting proxies the owner's own token to their Vision library; Cam keeps no template or crop. Person, package, vehicle, animal, loiter and door detections become one timeline event with a 10s/20s clip reference, a still, away-mode priority, a Network notification, a private link and a police download. Muted names and camera quiet hours hold alerts without losing the event.

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 423ef4b..b3f5d46 100644
---- a/.env.example
++++ b/.env.example
@@@ -34,6 +34,14 @@ CAM_EVENTS_ENDPOINT=https://openvibe.cam/internal/events
CAM_EVENTS_INTERVAL_MS=5000
CAM_EVENTS_SCOPE=events.subscription.manage
++# F1: the owner's own Vision face library (called with the owner's token), and the event timeline.
++OV_VISION_URL=https://openvibe.vision
++#CAM_CLIP_BEFORE_SECONDS=10
++#CAM_CLIP_AFTER_SECONDS=20
++#CAM_EVENT_MAX_BODY_MB=12
++#CAM_EVENT_SHARE_DAYS=7
++#CAM_NOTIFICATION_SCOPE=network.notifications.push
++
# ── The existing public APIs Cam uses ──
OV_MEDIA_URL=https://openvibe.media
OV_LIVE_URL=https://openvibe.live
ddiff --git a/README.md b/README.md
iindex 8ae3582..1fab428 100644
---- a/README.md
++++ b/README.md
@@@ -97,6 +97,22 @@ per-actor minute/hour limits (`CAM_LIMITS_MINUTE`, `CAM_LIMITS_HOUR`).
| `POST /stills` | a person; `{ slot, caption?, stream_id?, w?, visibility? }`; Media's frame → Pics → a still and the chat line |
| `GET /stills` | your stills |
| `GET /stills/:id` | an unlisted still by its link; a private one only for its owner |
++| `GET/PUT/DELETE /cameras/:id/familiar-faces` | the owner: turn Familiar faces on/off, set its region and quiet hours; on creates the owner's Vision library |
++| `POST /cameras/:id/familiar-faces/enroll` · `/match` · `GET /people` | the owner, proxied to Vision with the owner's own token; Cam keeps no template or crop |
++| `PATCH/DELETE /cameras/:id/familiar-faces/people/:personId` · `POST …/request` · `POST …/consent-check` | the owner: mute/rename/delete a name, or send/re-check its consent request |
++| `POST /cameras/:id/events` | the owner or camera: `{ kind, at?, still_url?, clip_url?, face_image?, face_image_url?, place?, lat?, lng?, linger_seconds? }`; one timeline event |
++| `GET /cameras/:id/events` · `GET /events` | the owner's timeline, newest first (`limit`, `kind`, `since`) |
++| `GET/PATCH /events/:id` · `POST /events/:id/share` · `POST /events/:id/police` · `GET /events/:id/export` | read, mark "this is X", a private link, or the police download (time, place, still and clip references) |
++| `GET /events/shared/:token` | anyone with the private token; the event, never the owner's account |
++| `GET/PUT /presence` | the owner's own home/away, from the browser, phone or Node |
++
++### Familiar faces and the timeline (F1)
++
++A camera's Familiar faces setting is `familiar_faces`; when it is on, Cam creates an OpenVibe.Vision library for that camera and stores only its `vision_library_id`, `region` and `region_mode`. The owner's own Network token is forwarded to Vision for every library call, so Vision decides ownership, encryption and consent; Cam holds no template and no crop. Deleting the camera deletes its Vision library first.
++
++An event is one detection: `person`, `package`, `vehicle`, `animal`, `loiter` or `door`. A person event may carry a face crop; only then does Cam ask Vision to match inside that camera's library. The event keeps a still and a clip reference plus `clip.before_seconds` (default 10) and `clip.after_seconds` (default 20), the owner's `away` state at that moment, and `priority: "high"` for an unknown person or a door while away. Detection without identity — package, vehicle, animal, loitering, door — never needs a face.
++
++One Network notification is pushed per event unless the matched name is muted or the camera's `quiet_hours` cover the event time; the event is still stored. `POST /events/:id/share` returns a private link once (only its sha256 is stored), and `GET /events/:id/export` returns an `attachment` file with the time and place for "Send to police". `OV_VISION_URL`, `CAM_CLIP_BEFORE_SECONDS`, `CAM_CLIP_AFTER_SECONDS`, `CAM_EVENT_MAX_BODY_MB`, `CAM_EVENT_SHARE_DAYS` and `CAM_NOTIFICATION_SCOPE` configure the path.
Validation refusals are `400 { code: "cam.invalid", message }`; a blocked caption is `400 { code:
"cam.blocked_words" }`. Other codes: `cam.sign_in_required`, `cam.person_required`, `cam.cross_site`,
ddiff --git a/STATUS.json b/STATUS.json
iindex 3680621..4173aeb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"stage": "product",
"code": true,
"wave": "W21",
-- "plan": "OpenVibe.Cam: the camera app, private cameras with revocable viewer grants, and the still Live's cam button takes — a photo and a still saved to OpenVibe.Pics by reference, with no picture bytes and no face recognition in Cam",
++ "plan": "OpenVibe.Cam: the camera app, private cameras with revocable viewer grants, the still Live's cam button takes, and F1 familiar faces for the owner's own cameras — a photo and a still saved to OpenVibe.Pics by reference, with no picture bytes in Cam and no face template or crop here",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5160",
"domain": "openvibe.cam",
@@@ -16,7 +16,7 @@
"openvibe-sdk v0.74.0",
"openvibe-shared v4.92.0"
],
-- "summary": "OpenVibe.Cam is the camera app, the private wall for cameras a person owns, and the API Live's cam button calls. The camera preview, timer, burst, grid and filter run in the browser; a photo is posted to Cam and forwarded to OpenVibe.Pics with the person's own Network token. A camera is private: its owner sees it, and a viewer sees it only while a grant naming them is unrevoked and unexpired, checked on every read, so a revocation is refused at once. Live's cam button calls Cam with the viewer's token; Cam reads OpenVibe.Media's public live frame, saves the JPEG to Pics as that person, and returns the line Live posts in the stream's chat. Cam keeps a Pics slug, links, filter and caption — never the picture bytes — and there is no face recognition.",
++ "summary": "OpenVibe.Cam is the camera app, the private wall for cameras a person owns, the API Live's cam button calls, and F1's event timeline. The camera preview, timer, burst, grid and filter run in the browser; a photo is posted to Cam and forwarded to OpenVibe.Pics with the person's own Network token. A camera is private: its owner sees it, and a viewer sees it only while a grant naming them is unrevoked and unexpired, checked on every read, so a revocation is refused at once. Familiar faces proxy the owner's own token to OpenVibe.Vision's library; Cam keeps only the Vision library id and no template or crop. A detection becomes one event with a 10 s/20 s clip reference, a still, away-mode priority, a Network notification, a private link and a police export.",
"works": [
"Cameras: pair a phone, browser, Pi or IP camera; a Live stream id is read from Live and must be on the owner's channel; the slot defaults to the stream's managed slot",
"The owner's camera page: rename, source, status, slot and note; unpair; the private wall at /w/<id>",
@@@ -25,6 +25,9 @@
"The camera app: live preview, timer, 3-shot burst, grid, on-device colour/light/softness, caption; the picture goes to Pics",
"The Live cam button: POST /api/v1/stills reads Media's GET /live/:slot/frame.jpg, uploads the JPEG to Pics with the viewer's own token, and returns the chat line",
"Photos and stills are stored by reference (Pics slug, page link, direct link, filter, caption); no image bytes, no face template and no classification",
++ "F1 familiar faces: a camera setting (region, quiet hours), the owner's Vision library id, and proxy enrol/match/mute/delete calls with the owner's own token",
++ "F1 event timeline: person/package/vehicle/animal/loiter/door, 10 s before and 20 s after clip references, a still, away mode and louder unknown/door alerts, muted names and camera quiet hours",
++ "F1 private share links (sha256 only) and the police export file with time and place",
"A still is unlisted by default (the chat link opens it) or private to its owner; a photo on a camera wall is unlisted, otherwise private",
"Sign-in with OpenVibe.Network (PKCE S256, cam_at/cam_rt/cam_oauth), truthful readiness and loopback metrics",
"Account export and deletion over cameras, grants given and received, photos and stills",
@@@ -32,13 +35,16 @@
],
"notYet": [
"The public camera directory, map, categories and follow-a-cam: v2; v1 makes nothing public and queues no Search document",
-- "Timelapses, the AI highlight of the day and Vision alerts: v2; Media has no timelapse",
++ "Timelapses and the AI highlight of the day: v2; Media has no timelapse",
"Face to face (random/group video, CAM-3): blocked on the owner's go/no-go after the safety gate and legal review",
"Video capture in the camera app: MediaHub's POST /api/v1/videos chunked upload is the path; v1 stores photos and stills only",
"Streamerless pairing of an IP camera: ingest is Live's RTMP path; v1 pairs and reads a Live slot",
"A Search relay: nothing is public in v1, so there is no cam_outbox",
"A staff moderation screen: staff have no bypass of camera privacy; a takedown is a report to Pics or Network"
],
++ "outboundCapabilities": [
++ "network.notifications.push"
++ ],
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"deployed": false,
@@@ -50,6 +56,9 @@
"private wall",
"viewer grants",
"grant revocation",
++ "familiar faces (Vision library proxy)",
++ "event timeline, away mode and notifications",
++ "private event links and police export",
"Live cam button stills",
"account export and deletion",
"Network sign-in",
ddiff --git a/migrations/0002_familiar_faces.sql b/migrations/0002_familiar_faces.sql
nnew file mode 100644
iindex 0000000..6c48675
---- /dev/null
++++ b/migrations/0002_familiar_faces.sql
@@@ -0,0 +1,82 @@
++-- phase: expand
++-- F1: familiar faces for a camera owner's own cameras (plan L24, 2026-10-31).
++--
++-- Cam keeps no face template and no face crop: a template and its crop stay in OpenVibe.Vision's library, scoped to
++-- the owner. This side keeps the camera's setting, the Vision library id it points at, and the event timeline. An
++-- event's clip and still are opaque private references (a Pics/Media URL today) or null; Cam never holds bytes.
++
++ALTER TABLE cam_cameras ADD COLUMN familiar_faces boolean NOT NULL DEFAULT false;
++ALTER TABLE cam_cameras ADD COLUMN vision_library_id text;
++ALTER TABLE cam_cameras ADD COLUMN region text COLLATE "C" NOT NULL DEFAULT 'unknown';
++ALTER TABLE cam_cameras ADD COLUMN region_mode text COLLATE "C" NOT NULL DEFAULT 'strict';
++ALTER TABLE cam_cameras ADD COLUMN quiet_hours jsonb NOT NULL DEFAULT '{"enabled":false,"start":"22:00","end":"07:00","tz":"UTC"}'::jsonb;
++ALTER TABLE cam_cameras ADD CONSTRAINT cam_cameras_region CHECK (length(region) BETWEEN 1 AND 40);
++ALTER TABLE cam_cameras ADD CONSTRAINT cam_cameras_region_mode CHECK (region_mode IN ('strict', 'open'));
++ALTER TABLE cam_cameras ADD CONSTRAINT cam_cameras_quiet CHECK (jsonb_typeof(quiet_hours) = 'object');
++
++-- One timeline row per detected event. `kind` is the detection class; a named face is the optional match from the
++-- camera's Vision library. away is the owner's presence at the time, high marks the louder alerts.
++CREATE TABLE cam_events (
++ id text COLLATE "C" PRIMARY KEY, -- cev_ + ULID
++ camera_id text COLLATE "C" NOT NULL REFERENCES cam_cameras (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL
++ CHECK (kind IN ('person', 'package', 'vehicle', 'animal', 'loiter', 'door')),
++ at timestamptz NOT NULL,
++ title text NOT NULL,
++ detail text,
++ person_id text COLLATE "C",
++ person_name text,
++ known boolean NOT NULL DEFAULT false,
++ muted boolean NOT NULL DEFAULT false,
++ away boolean NOT NULL DEFAULT false,
++ priority text COLLATE "C" NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'critical')),
++ still_url text,
++ clip_url text,
++ clip_before_seconds integer NOT NULL DEFAULT 10 CHECK (clip_before_seconds BETWEEN 0 AND 60),
++ clip_after_seconds integer NOT NULL DEFAULT 20 CHECK (clip_after_seconds BETWEEN 0 AND 120),
++ place text,
++ lat double precision,
++ lng double precision,
++ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
++ notified boolean NOT NULL DEFAULT false,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT cam_events_id CHECK (id ~ '^cev_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT cam_events_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT cam_events_title CHECK (length(title) BETWEEN 1 AND 240),
++ CONSTRAINT cam_events_detail CHECK (detail IS NULL OR length(detail) <= 500),
++ CONSTRAINT cam_events_still CHECK (still_url IS NULL OR still_url ~ '^https?://'),
++ CONSTRAINT cam_events_clip CHECK (clip_url IS NULL OR clip_url ~ '^https?://'),
++ CONSTRAINT cam_events_lat CHECK (lat IS NULL OR (lat >= -90 AND lat <= 90)),
++ CONSTRAINT cam_events_lng CHECK (lng IS NULL OR (lng >= -180 AND lng <= 180))
++);
++CREATE INDEX cam_events_timeline ON cam_events (camera_id, at DESC, id DESC);
++CREATE INDEX cam_events_by_owner ON cam_events (owner, at DESC);
++
++-- Home or away as the person's phone or browser last said. One row per owner; an event keeps the value it saw.
++CREATE TABLE cam_presence (
++ owner text COLLATE "C" PRIMARY KEY,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('home', 'away')),
++ source text COLLATE "C" NOT NULL DEFAULT 'browser' CHECK (source IN ('browser', 'phone', 'node')),
++ camera_id text COLLATE "C" REFERENCES cam_cameras (id) ON DELETE SET NULL,
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT cam_presence_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
++);
++
++-- A private link to one event (or the police export). Only the sha256 of the token is stored; the token is returned
++-- once. A revoked or expired link answers 404 like a link that never existed.
++CREATE TABLE cam_event_shares (
++ id text COLLATE "C" PRIMARY KEY, -- ces_ + ULID
++ event_id text COLLATE "C" NOT NULL REFERENCES cam_events (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL,
++ purpose text COLLATE "C" NOT NULL DEFAULT 'link' CHECK (purpose IN ('link', 'police')),
++ token_hash text COLLATE "C" NOT NULL UNIQUE,
++ expires_at timestamptz NOT NULL,
++ revoked_at timestamptz,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT cam_event_shares_id CHECK (id ~ '^ces_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT cam_event_shares_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT cam_event_shares_token CHECK (token_hash ~ '^[0-9a-f]{64}$'),
++ CONSTRAINT cam_event_shares_expiry CHECK (expires_at > created_at)
++);
++CREATE INDEX cam_event_shares_by_event ON cam_event_shares (event_id, created_at DESC);
ddiff --git a/server/app.js b/server/app.js
iindex 1f21779..5a16138 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { raw } = require('./render/html');
const accountDataLib = require('./identity/account-data');
const { createStore: createCamStore } = require('./store');
const { createUpstreams } = require('./upstreams');
++const { createEventPipeline } = require('./events/pipeline');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@@ -55,6 +56,7 @@ async function createApp(opts = {}) {
const cameras = createCamStore(s, { config });
const upstreams = createUpstreams({ config, fetchImpl, log });
++ const events = createEventPipeline({ cameras, upstreams, config, now: s.now });
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@@ -63,7 +65,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Cam cannot answer account events'); });
-- const ctx = { config, s, keys, sso, principal, cameras, upstreams, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, cameras, upstreams, events, accountData, accountSend, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/config.js b/server/config.js
iindex e3c453b..e9a6c3a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -67,13 +67,27 @@ function load(env = process.env) {
// OpenVibe.Media GET /live/:sel/frame.jpg a near-realtime JPEG of a live slot (public, CORS-open)
// OpenVibe.Live GET /api/streams/:id a stream and its channel (public; presence is not ownership)
// OpenVibe.Pics POST /api/v1/images called with the signed-in person's own Network token
++ // OpenVibe.Vision familiar-face libraries called with the same person's token (the owner's own library)
upstreams: {
media: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
pics: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
++ vision: trim(env.OV_VISION_URL || 'https://openvibe.vision'),
timeoutMs: Math.max(500, int(env.CAM_UPSTREAM_TIMEOUT_MS, 8000)),
},
++ // F1 event timeline. A clip is 10 s before and 20 s after the event; the camera uploads or links it, Cam
++ // stores only the reference. `notify` pushes one Network notification per event unless a name is muted or
++ // the camera's quiet hours are on.
++ timeline: {
++ maxBodyMb: Math.min(24, Math.max(1, int(env.CAM_EVENT_MAX_BODY_MB, 12))),
++ clipBeforeSeconds: Math.min(60, Math.max(0, int(env.CAM_CLIP_BEFORE_SECONDS, 10))),
++ clipAfterSeconds: Math.min(120, Math.max(0, int(env.CAM_CLIP_AFTER_SECONDS, 20))),
++ shareDays: Math.min(30, Math.max(1, int(env.CAM_EVENT_SHARE_DAYS, 7))),
++ notificationService: env.CAM_NOTIFICATION_SERVICE || 'cam',
++ notificationScope: env.CAM_NOTIFICATION_SCOPE || 'network.notifications.push',
++ },
++
// A photo the camera app uploads, and the still Live's cam button takes. Neither is ever stored here:
// the bytes go to OpenVibe.Pics, and Cam keeps the Pics slug and links.
photos: { maxBytes: size(env.CAM_PHOTO_MAX_BYTES, 12 * 1024 * 1024) },
ddiff --git a/server/events/pipeline.js b/server/events/pipeline.js
nnew file mode 100644
iindex 0000000..5cf5996
---- /dev/null
++++ b/server/events/pipeline.js
@@@ -0,0 +1,144 @@
++'use strict';
++/**
++ * F1's event pipeline: a motion/detection report becomes one timeline event, and only a person event with a face
++ * crop asks Vision to match inside that camera's own library.
++ *
++ * detect (the camera) → kind and a 10 s-before / 20 s-after clip and still → (person) face crop → Vision
++ * library match → event → Notification (held while the name is muted or quiet hours are on) → timeline
++ *
++ * Detection without identity never needs a face: package, vehicle, animal, loitering and door events are stored
++ * as they arrive. A Vision refusal or outage is not an error the camera has to retry: the event is kept as an
++ * unknown person and the metadata says the match did not run.
++ */
++const { refusal } = require('../cameras');
++
++const KIND_LABEL = Object.freeze({
++ person: 'person', package: 'package', vehicle: 'vehicle', animal: 'animal', loiter: 'someone lingering', door: 'door',
++});
++
++function parseQuietTime(v) {
++ const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(v || ''));
++ return m ? Number(m[1]) * 60 + Number(m[2]) : null;
++}
++
++function minutesInZone(atMs, tz) {
++ try {
++ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: tz || 'UTC', hour: '2-digit', minute: '2-digit', hour12: false })
++ .formatToParts(new Date(atMs));
++ const h = Number(parts.find((p) => p.type === 'hour').value);
++ const m = Number(parts.find((p) => p.type === 'minute').value);
++ return (h % 24) * 60 + m;
++ } catch {
++ return null;
++ }
++}
++
++/** The camera's own quiet window; a malformed one never holds an alert. */
++function inQuietHours(quiet, atMs) {
++ if (!quiet || quiet.enabled !== true) return false;
++ const start = parseQuietTime(quiet.start), end = parseQuietTime(quiet.end);
++ if (start == null || end == null || start === end) return false;
++ const mins = minutesInZone(atMs, quiet.tz);
++ if (mins == null) return false;
++ return start < end ? mins >= start && mins < end : mins >= start || mins < end;
++}
++
++function titleFor(kind, camera, { personName = null, away = false, lingerSeconds = null } = {}) {
++ const place = String(camera.name || 'camera').replace(/\s+/g, ' ').trim();
++ if (kind === 'person') return personName ? `${personName} is at the ${place}` : `Unknown person at the ${place}`;
++ if (kind === 'package') return 'Package left';
++ if (kind === 'vehicle') return /driveway|garage|parking|car park/i.test(place) ? `Car in the ${place.toLowerCase()}` : `Car at the ${place}`;
++ if (kind === 'animal') return `Animal at the ${place}`;
++ if (kind === 'loiter') {
++ const n = Math.max(1, Math.round((Number(lingerSeconds) || 120) / 60));
++ return `Someone lingered ${n} min`;
++ }
++ if (kind === 'door') return away ? "The door opened while you're away" : 'The door opened';
++ return `Movement at the ${place}`;
++}
++
++function createEventPipeline({ cameras, upstreams, config, now = () => Date.now() }) {
++ const clock = () => new Date(now()).toISOString();
++
++ async function ingest({ owner, token, camera, body = {} }) {
++ const kind = String(body.kind || '').trim();
++ if (!cameras.EVENT_KINDS.includes(kind)) return refusal(400, 'cam.invalid', `kind must be one of ${cameras.EVENT_KINDS.join(', ')}`, 'kind');
++
++ let atMs = now();
++ if (body.at != null && body.at !== '') {
++ atMs = Date.parse(String(body.at));
++ if (!Number.isFinite(atMs)) return refusal(400, 'cam.invalid', 'at must be an ISO date-time', 'at');
++ if (atMs > now() + 5 * 60_000 || atMs < now() - 7 * 86_400_000) return refusal(400, 'cam.invalid', 'at is outside the last 7 days', 'at');
++ }
++ const at = new Date(atMs).toISOString();
++
++ let personId = null, personName = null, known = false, muted = false, matchNote = null;
++ const faceImage = body.face_image != null ? String(body.face_image) : body.face_image_url != null ? String(body.face_image_url) : null;
++ if (kind === 'person' && faceImage && camera.familiar_faces && camera.vision_library_id) {
++ if (body.face_image != null && !/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(faceImage)) {
++ return refusal(400, 'cam.invalid', 'face_image must be a JPEG, PNG or WebP data URL', 'face_image');
++ }
++ if (body.face_image_url != null && !/^https?:\/\//.test(faceImage)) return refusal(400, 'cam.invalid', 'face_image_url must be an http(s) URL', 'face_image_url');
++ try {
++ const got = await upstreams.vision.match({ token, libraryId: camera.vision_library_id, image: faceImage });
++ if (got && got.match === true) {
++ personId = got.person_id; personName = got.name; known = true; muted = Boolean(got.muted);
++ }
++ } catch (err) {
++ // The event still exists: an unknown person is honest, and the camera must not lose the clip.
++ matchNote = err && err.code ? String(err.code).slice(0, 80) : 'match_unavailable';
++ }
++ }
++ if (known === false && body.person_name != null && body.person_name !== '') {
++ // The owner can correct the label later through PATCH; an event may also arrive already named by the
++ // owner's own device. No name from another library is ever used here.
++ personName = String(body.person_name).replace(/\s+/g, ' ').trim().slice(0, 80) || null;
++ personId = body.person_id == null ? null : String(body.person_id).slice(0, 40);
++ known = Boolean(personName);
++ }
++
++ const presence = await cameras.presenceOf(owner);
++ const away = presence.state === 'away';
++ const priority = away && ((kind === 'person' && !known) || kind === 'door') ? 'high' : 'normal';
++ const title = titleFor(kind, camera, { personName, away, lingerSeconds: body.linger_seconds });
++ const quiet = inQuietHours(camera.quiet_hours, atMs);
++ const notifyAllowed = body.notify !== false;
++
++ let notification = { sent: false, skipped: 'not_requested' };
++ if (notifyAllowed && muted) notification = { sent: false, skipped: 'muted' };
++ else if (notifyAllowed && quiet) notification = { sent: false, skipped: 'quiet_hours' };
++ else if (notifyAllowed) {
++ notification = await upstreams.pushNotification({
++ subject: owner, title, message: title, priority,
++ url: `${String(config.baseUrl).replace(/\/+$/, '')}/my/cameras/${camera.id}`,
++ type: 'CAM_EVENT', category: 'cam',
++ });
++ }
++
++ const metadata = {};
++ if (body.linger_seconds != null) metadata.linger_seconds = Math.max(1, Math.min(86_400, Number(body.linger_seconds) || 0));
++ if (body.motion === true) metadata.motion = true;
++ if (matchNote) metadata.face_match = matchNote;
++ if (body.metadata && typeof body.metadata === 'object') Object.assign(metadata, body.metadata);
++
++ const created = await cameras.createEvent({
++ cameraId: camera.id, owner, kind, at, title,
++ detail: body.detail == null ? null : String(body.detail),
++ personId, personName, known, muted, away, priority,
++ stillUrl: body.still_url == null ? null : String(body.still_url),
++ clipUrl: body.clip_url == null ? null : String(body.clip_url),
++ clipBefore: body.clip_before_seconds == null ? config.timeline.clipBeforeSeconds : Number(body.clip_before_seconds),
++ clipAfter: body.clip_after_seconds == null ? config.timeline.clipAfterSeconds : Number(body.clip_after_seconds),
++ place: body.place == null ? null : String(body.place),
++ lat: body.lat == null ? null : Number(body.lat),
++ lng: body.lng == null ? null : Number(body.lng),
++ metadata, notified: notification.sent === true,
++ });
++ if (!created.ok) return created;
++ return { ok: true, event: created.event, notification };
++ }
++
++ return { ingest, titleFor, inQuietHours };
++}
++
++module.exports = { createEventPipeline, titleFor, inQuietHours, KIND_LABEL };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7739d09..2c2ccb3 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -23,6 +23,7 @@
* token as a Bearer; a session write must come from openvibe.cam itself (403 cam.cross_site). No route needs an app
* or service token: the still is saved to the viewer's own Pics with the viewer's own token.
*/
++const crypto = require('crypto');
const express = require('express');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
@@@ -31,7 +32,7 @@ const { pairCamera, resolveViewer } = require('../cameras');
const { frameUrl } = require('../views');
function createApi(ctx) {
-- const { config, s, principal, cameras, upstreams } = ctx;
++ const { config, s, principal, cameras, upstreams, events } = ctx;
const r = asyncRouter();
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@@ -89,7 +90,13 @@ function createApi(ctx) {
// A JSON body everywhere except the photo upload, whose body is the picture itself.
const jsonBody = express.json({ limit: '64kb' });
-- r.use((req, res, next) => ((req.method === 'POST' && req.path === '/photos') ? next() : jsonBody(req, res, next)));
++ const eventBody = express.json({ limit: `${config.timeline.maxBodyMb}mb` });
++ const familiarBody = express.json({ limit: `${config.timeline.maxBodyMb}mb` });
++ r.use((req, res, next) => {
++ if (req.method === 'POST' && req.path === '/photos') return next();
++ if (req.method === 'POST' && (/^\/cameras\/[^/]+\/events$/.test(req.path) || /^\/cameras\/[^/]+\/familiar-faces\/(enroll|match)$/.test(req.path))) return next();
++ return jsonBody(req, res, next);
++ });
// ── Cameras ─────────────────────────────────────────────
r.get('/cameras', requirePerson, async (req, res) => {
@@@ -131,6 +138,12 @@ function createApi(ctx) {
r.delete('/cameras/:id', requirePerson, async (req, res) => {
const row = await ownCamera(req, res);
if (!row) return undefined;
++ // Deleting the camera deletes its familiar-faces library too (F1). A Vision outage refuses the unpair,
++ // so the owner never leaves an unowned library behind.
++ if (row.vision_library_id) {
++ try { await upstreams.vision.deleteLibrary({ token: req.principal.token, libraryId: row.vision_library_id }); }
++ catch (err) { if (!(err && err.status === 404)) return upstreamProblem(res, err); }
++ }
const out = await cameras.removeCamera(row.id, req.principal.subject);
if (!out.ok) return problem(res, out.status, out.code, out.message);
return res.status(204).end();
@@@ -140,7 +153,10 @@ function createApi(ctx) {
const wall = await cameras.wall(String(req.params.id || ''), req.principal.subject);
if (!wall) return problem(res, 404, 'cam.not_found', 'No such camera, or you have no grant for it.');
const camera = { ...cameras.wireCamera(wall.camera), ...(wall.owner ? {} : { notes: undefined }) };
-- if (!wall.owner) delete camera.notes;
++ if (!wall.owner) {
++ delete camera.notes;
++ for (const key of ['familiar_faces', 'vision_library_id', 'region', 'region_mode', 'quiet_hours']) delete camera[key];
++ }
return res.json({
camera,
owner: wall.owner,
@@@ -150,6 +166,278 @@ function createApi(ctx) {
});
});
++ // ── F1: familiar faces ──────────────────────────────────
++ // The owner's page talks to Cam; Cam passes the owner's own Network token to Vision and keeps no template or
++ // crop here. A proxy refusal is Vision's own problem detail.
++ async function familiarLibrary(req, res) {
++ const row = await ownCamera(req, res);
++ if (!row) return null;
++ if (!row.vision_library_id) {
++ problem(res, 409, 'cam.familiar_faces_off', 'Turn on Familiar faces for this camera first.');
++ return null;
++ }
++ return { row, libraryId: row.vision_library_id, token: req.principal.token };
++ }
++
++ r.get('/cameras/:id/familiar-faces', requirePerson, async (req, res) => {
++ const row = await ownCamera(req, res);
++ if (!row) return undefined;
++ const presence = await cameras.presenceOf(req.principal.subject);
++ let library = null;
++ if (row.vision_library_id) {
++ try { library = await upstreams.vision.getLibrary({ token: req.principal.token, libraryId: row.vision_library_id }); }
++ catch (err) { if (!(err && err.status === 404)) return upstreamProblem(res, err); }
++ }
++ return res.json({
++ camera_id: row.id,
++ enabled: Boolean(row.familiar_faces),
++ region: row.region,
++ region_mode: row.region_mode,
++ quiet_hours: row.quiet_hours,
++ away: presence.state === 'away',
++ library,
++ people: library && Array.isArray(library.people) ? library.people : [],
++ });
++ });
++
++ r.put('/cameras/:id/familiar-faces', requirePerson, async (req, res) => {
++ const row = await ownCamera(req, res);
++ if (!row) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const allowed = ['enabled', 'region', 'quiet_hours', 'threshold'];
++ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));
++ if (unknown.length) return invalid(res, `Unknown field: ${unknown[0]}`);
++ if (b.enabled !== undefined && typeof b.enabled !== 'boolean') return invalid(res, 'enabled must be true or false');
++ let library = null;
++ if (b.enabled === true) {
++ try {
++ if (!row.vision_library_id) {
++ library = await upstreams.vision.createLibrary({
++ token: req.principal.token, cameraIds: [row.id],
++ region: b.region == null || b.region === '' ? row.region : String(b.region).slice(0, 40),
++ threshold: b.threshold == null ? undefined : Number(b.threshold),
++ });
++ } else if (b.region !== undefined || b.threshold !== undefined) {
++ library = await upstreams.vision.patchLibrary({
++ token: req.principal.token, libraryId: row.vision_library_id,
++ body: { ...(b.region === undefined ? {} : { region: String(b.region).slice(0, 40) }), ...(b.threshold === undefined ? {} : { threshold: Number(b.threshold) }) },
++ });
++ }
++ } catch (err) { return upstreamProblem(res, err); }
++ }
++ const out = await cameras.setFamiliarFaces(row.id, req.principal.subject, {
++ enabled: b.enabled === undefined ? row.familiar_faces : b.enabled,
++ libraryId: library ? library.id : undefined,
++ region: library ? library.region : (b.region === undefined ? undefined : String(b.region).slice(0, 40)),
++ regionMode: library ? library.region_mode : undefined,
++ quietHours: b.quiet_hours,
++ });
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ return res.json({
++ camera_id: out.camera.id, enabled: out.camera.familiar_faces, region: out.camera.region,
++ region_mode: out.camera.region_mode, quiet_hours: out.camera.quiet_hours, library,
++ });
++ });
++
++ r.delete('/cameras/:id/familiar-faces', requirePerson, async (req, res) => {
++ const row = await ownCamera(req, res);
++ if (!row) return undefined;
++ if (row.vision_library_id) {
++ try { await upstreams.vision.deleteLibrary({ token: req.principal.token, libraryId: row.vision_library_id }); }
++ catch (err) { if (!(err && err.status === 404)) return upstreamProblem(res, err); }
++ }
++ const out = await cameras.setFamiliarFaces(row.id, req.principal.subject, { enabled: false, libraryId: null });
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ return res.json({ deleted: true, camera_id: row.id });
++ });
++
++ r.post('/cameras/:id/familiar-faces/enroll', requirePerson, familiarBody, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const image = b.image == null ? '' : String(b.image);
++ if (!/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) return invalid(res, 'image must be a JPEG, PNG or WebP data URL');
++ const name = String(b.name == null ? '' : b.name).replace(/\s+/g, ' ').trim();
++ if (!name || name.length > 80) return invalid(res, 'name is 1 to 80 characters of plain text');
++ try {
++ const out = await upstreams.vision.enroll({ token: got.token, libraryId: got.libraryId, image, name, subject: b.subject == null ? null : String(b.subject) });
++ return res.status(201).json({ ...out, camera_id: got.row.id, library_id: got.libraryId });
++ } catch (err) { return upstreamProblem(res, err); }
++ });
++
++ r.post('/cameras/:id/familiar-faces/match', requirePerson, familiarBody, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ const image = req.body && req.body.image == null ? '' : String(req.body.image);
++ if (!/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) return invalid(res, 'image must be a JPEG, PNG or WebP data URL');
++ try { return res.json(await upstreams.vision.match({ token: got.token, libraryId: got.libraryId, image })); }
++ catch (err) { return upstreamProblem(res, err); }
++ });
++
++ r.get('/cameras/:id/familiar-faces/people', requirePerson, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ try { return res.json(await upstreams.vision.people({ token: got.token, libraryId: got.libraryId })); }
++ catch (err) { return upstreamProblem(res, err); }
++ });
++
++ r.patch('/cameras/:id/familiar-faces/people/:personId', requirePerson, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const body = {};
++ if (b.name !== undefined) body.name = String(b.name);
++ if (b.muted !== undefined) body.muted = Boolean(b.muted);
++ try { return res.json(await upstreams.vision.patchPerson({ token: got.token, libraryId: got.libraryId, personId: req.params.personId, body })); }
++ catch (err) { return upstreamProblem(res, err); }
++ });
++
++ r.delete('/cameras/:id/familiar-faces/people/:personId', requirePerson, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ try { return res.json(await upstreams.vision.removePerson({ token: got.token, libraryId: got.libraryId, personId: req.params.personId })); }
++ catch (err) { return upstreamProblem(res, err); }
++ });
++
++ r.post('/cameras/:id/familiar-faces/people/:personId/request', requirePerson, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ try { return res.json(await upstreams.vision.requestConsent({ token: got.token, libraryId: got.libraryId, personId: req.params.personId })); }
++ catch (err) { return upstreamProblem(res, err); }
++ });
++
++ r.post('/cameras/:id/familiar-faces/consent-check', requirePerson, async (req, res) => {
++ const got = await familiarLibrary(req, res);
++ if (!got) return undefined;
++ try { return res.json(await upstreams.vision.checkConsent({ token: got.token, libraryId: got.libraryId })); }
++ catch (err) { return upstreamProblem(res, err); }
++ });
++
++ // ── F1: the event timeline ──────────────────────────────
++ // A detection arrives with its own clip/still reference; a person may arrive with a face crop. Cam asks Vision
++ // to match only this camera's library and keeps the event even when the match cannot run.
++ r.post('/cameras/:id/events', requirePerson, eventBody, async (req, res) => {
++ const row = await ownCamera(req, res);
++ if (!row) return undefined;
++ const out = await events.ingest({ owner: req.principal.subject, token: req.principal.token, camera: row, body: req.body || {} });
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ res.set('Location', `/api/v1/events/${out.event.id}`);
++ return res.status(201).json({ event: out.event, notification: out.notification });
++ });
++
++ r.get('/cameras/:id/events', requirePerson, async (req, res) => {
++ const row = await ownCamera(req, res);
++ if (!row) return undefined;
++ const list = await cameras.eventsOf({
++ owner: req.principal.subject, cameraId: row.id,
++ limit: asInt(req.query.limit, 60), kind: req.query.kind ? String(req.query.kind) : null,
++ since: req.query.since ? String(req.query.since) : null,
++ });
++ return res.json({ count: list.length, camera_id: row.id, events: list });
++ });
++
++ r.get('/events', requirePerson, async (req, res) => {
++ const list = await cameras.eventsOf({
++ owner: req.principal.subject, limit: asInt(req.query.limit, 60),
++ kind: req.query.kind ? String(req.query.kind) : null, since: req.query.since ? String(req.query.since) : null,
++ });
++ return res.json({ count: list.length, events: list });
++ });
++
++ // A private link the owner shares; the token is returned once and only its sha256 is stored.
++ r.post('/events/:id/share', requirePerson, async (req, res) => {
++ const event = await cameras.eventById(String(req.params.id || ''), req.principal.subject);
++ if (!event) return problem(res, 404, 'cam.not_found', 'No such event.');
++ const purpose = req.body && req.body.purpose === 'police' ? 'police' : 'link';
++ const days = Math.min(config.timeline.shareDays, Math.max(1, asInt(req.body && req.body.days, config.timeline.shareDays)));
++ const token = crypto.randomBytes(24).toString('base64url');
++ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
++ const expiresAt = new Date(s.now() + days * 86_400_000).toISOString();
++ const out = await cameras.shareEvent({ eventId: event.id, owner: req.principal.subject, purpose, tokenHash, expiresAt });
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ const base = String(config.baseUrl).replace(/\/+$/, '');
++ return res.status(201).json({
++ share: out.share, purpose, token,
++ url: `${base}/e/${token}`,
++ api_url: `${base}/api/v1/events/shared/${token}`,
++ expires_at: expiresAt,
++ });
++ });
++
++ r.get('/events/shared/:token', async (req, res) => {
++ const raw = String(req.params.token || '');
++ if (!/^[A-Za-z0-9_-]{20,80}$/.test(raw)) return problem(res, 404, 'cam.not_found', 'No such shared event.');
++ const got = await cameras.eventByShare(crypto.createHash('sha256').update(raw).digest('hex'));
++ if (!got) return problem(res, 404, 'cam.not_found', 'No such shared event.');
++ return res.json({ event: got.event, purpose: got.purpose, expires_at: got.expires_at });
++ });
++
++ r.get('/events/:id/export', requirePerson, async (req, res) => {
++ const row = await cameras.eventById(String(req.params.id || ''), req.principal.subject);
++ if (!row) return problem(res, 404, 'cam.not_found', 'No such event.');
++ const camera = await cameras.cameraById(row.camera_id);
++ const file = {
++ kind: 'cam.event.police-export',
++ version: 1,
++ exported_at: new Date(s.now()).toISOString(),
++ event: {
++ id: row.id, kind: row.kind, at: row.at, title: row.title,
++ person_name: row.person_name, known: row.known,
++ still_url: row.still_url, clip: row.clip,
++ time: row.at, place: row.location,
++ },
++ camera: camera ? { id: camera.id, name: camera.name } : { id: row.camera_id, name: null },
++ note: 'This file records the event time and place and links to the still and the clip. The clip itself stays in the private reference.',
++ };
++ res.setHeader('Content-Disposition', `attachment; filename="cam-event-${row.id}.json"`);
++ return res.type('application/json').send(JSON.stringify(file, null, 2));
++ });
++
++ r.post('/events/:id/police', requirePerson, async (req, res) => {
++ const event = await cameras.eventById(String(req.params.id || ''), req.principal.subject);
++ if (!event) return problem(res, 404, 'cam.not_found', 'No such event.');
++ const base = String(config.baseUrl).replace(/\/+$/, '');
++ return res.json({
++ event_id: event.id,
++ download_url: `${base}/api/v1/events/${event.id}/export`,
++ purpose: 'police',
++ time: event.at,
++ place: event.location,
++ clip: event.clip,
++ still_url: event.still_url,
++ });
++ });
++
++ r.get('/events/:id', requirePerson, async (req, res) => {
++ const event = await cameras.eventById(String(req.params.id || ''), req.principal.subject);
++ if (!event) return problem(res, 404, 'cam.not_found', 'No such event.');
++ return res.json({ event });
++ });
++
++ r.patch('/events/:id', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await cameras.annotateEvent(String(req.params.id || ''), req.principal.subject, {
++ personName: b.person_name, personId: b.person_id, muted: b.muted,
++ });
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ return res.json({ event: out.event });
++ });
++
++ // ── F1: away mode ───────────────────────────────────────
++ r.get('/presence', requirePerson, async (req, res) => {
++ const presence = await cameras.presenceOf(req.principal.subject);
++ return res.json({ presence });
++ });
++
++ r.put('/presence', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await cameras.setPresence(req.principal.subject, {
++ state: b.state, source: b.source || 'browser', cameraId: b.camera_id == null || b.camera_id === '' ? null : String(b.camera_id),
++ });
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ return res.json({ presence: out.presence });
++ });
++
// ── Viewer grants ───────────────────────────────────────
r.get('/cameras/:id/grants', requirePerson, async (req, res) => {
const out = await cameras.grantsOf(String(req.params.id || ''), req.principal.subject);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex a8c44c0..309b31f 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -12,6 +12,10 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
++ // F1's timeline and presence are the owner's. Shares and events go before the camera they hang under.
++ { table: 'cam_event_shares', subject: 'owner', file: 'event-shares.json', erase: 'delete' },
++ { table: 'cam_events', subject: 'owner', file: 'events.json', erase: 'delete' },
++ { table: 'cam_presence', subject: 'owner', file: 'presence.json', erase: 'delete' },
{ table: 'cam_cameras', subject: 'owner', file: 'cameras.json', erase: 'delete' },
{ table: 'cam_grants', subject: 'owner', file: 'grants-given.json', erase: 'delete' },
{ table: 'cam_grants', subject: 'viewer_subject', file: 'grants-received.json', erase: 'delete' },
ddiff --git a/server/store.js b/server/store.js
iindex a00e67d..33e1f09 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -11,6 +11,10 @@ const SOURCES = Object.freeze(['browser', 'phone', 'pi', 'ip']);
const STATUSES = Object.freeze(['paired', 'live', 'offline']);
const FILTERS = Object.freeze(['none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur']);
const STILL_VISIBILITIES = Object.freeze(['unlisted', 'private']);
++const EVENT_KINDS = Object.freeze(['person', 'package', 'vehicle', 'animal', 'loiter', 'door']);
++const PRIORITIES = Object.freeze(['low', 'normal', 'high', 'critical']);
++const PRESENCE = Object.freeze(['home', 'away']);
++const SHARE_PURPOSES = Object.freeze(['link', 'police']);
const PHOTO_VISIBILITIES = Object.freeze(['private', 'unlisted']);
const CAPTION_MAX = 280;
const NAME_MAX = 80;
@@@ -61,6 +65,11 @@ function wireCamera(row) {
slot: row.slot == null ? null : row.slot,
notes: row.notes == null ? null : row.notes,
owner_username: row.owner_username == null ? null : row.owner_username,
++ familiar_faces: Boolean(row.familiar_faces),
++ vision_library_id: row.vision_library_id == null ? null : row.vision_library_id,
++ region: row.region == null ? 'unknown' : row.region,
++ region_mode: row.region_mode == null ? 'strict' : row.region_mode,
++ quiet_hours: row.quiet_hours == null ? null : row.quiet_hours,
paired_at: iso(row.paired_at),
last_seen_at: iso(row.last_seen_at),
created_at: iso(row.created_at),
@@@ -85,6 +94,54 @@ function wireGrant(row, { now = Date.now() } = {}) {
};
}
++function wireEvent(row) {
++ if (!row) return null;
++ const clip = row.clip_url == null ? null : {
++ url: row.clip_url,
++ before_seconds: Number(row.clip_before_seconds) || 0,
++ after_seconds: Number(row.clip_after_seconds) || 0,
++ };
++ const location = row.place == null && row.lat == null && row.lng == null ? null : {
++ place: row.place == null ? null : row.place,
++ lat: row.lat == null ? null : Number(row.lat),
++ lng: row.lng == null ? null : Number(row.lng),
++ };
++ let metadata = row.metadata;
++ if (typeof metadata === 'string') { try { metadata = JSON.parse(metadata); } catch { metadata = {}; } }
++ return {
++ id: row.id,
++ camera_id: row.camera_id,
++ kind: row.kind,
++ at: iso(row.at),
++ title: row.title,
++ detail: row.detail == null ? null : row.detail,
++ person_id: row.person_id == null ? null : row.person_id,
++ person_name: row.person_name == null ? null : row.person_name,
++ known: Boolean(row.known),
++ muted: Boolean(row.muted),
++ away: Boolean(row.away),
++ priority: row.priority,
++ still_url: row.still_url == null ? null : row.still_url,
++ clip,
++ location,
++ metadata: metadata && typeof metadata === 'object' ? metadata : {},
++ notified: Boolean(row.notified),
++ created_at: iso(row.created_at),
++ };
++}
++
++function wireShare(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ event_id: row.event_id,
++ purpose: row.purpose,
++ expires_at: iso(row.expires_at),
++ revoked_at: iso(row.revoked_at),
++ created_at: iso(row.created_at),
++ };
++}
++
function wirePhoto(row, { owner = false } = {}) {
if (!row) return null;
return {
@@@ -358,6 +415,132 @@ function createStore(s, { config }) {
return rows.map((r) => wireStill(r, { owner: true }));
}
++ // ── F1: familiar-faces setting, presence, events and shared links ──
++ async function setFamiliarFaces(id, owner, { enabled, libraryId, region, regionMode, quietHours } = {}) {
++ const row = await cameraById(id);
++ if (!row || row.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
++ const sets = [];
++ const args = [id];
++ if (enabled !== undefined) { args.push(Boolean(enabled)); sets.push(`familiar_faces=$${args.length}`); }
++ if (libraryId !== undefined) { args.push(libraryId == null ? null : String(libraryId)); sets.push(`vision_library_id=$${args.length}`); }
++ if (region !== undefined) { args.push(String(region).slice(0, 40)); sets.push(`region=$${args.length}`); }
++ if (regionMode !== undefined) {
++ if (!['strict', 'open'].includes(regionMode)) return refusal(400, 'cam.invalid', 'region_mode must be strict or open', 'region_mode');
++ args.push(regionMode); sets.push(`region_mode=$${args.length}`);
++ }
++ if (quietHours !== undefined) {
++ const q = quietHours && typeof quietHours === 'object' ? quietHours : {};
++ const tz = q.tz == null ? 'UTC' : String(q.tz).slice(0, 64);
++ try { new Intl.DateTimeFormat('en-GB', { timeZone: tz }); } catch { return refusal(400, 'cam.invalid', 'quiet_hours.tz is not a time zone', 'quiet_hours'); }
++ for (const k of ['start', 'end']) {
++ if (q[k] != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(String(q[k]))) return refusal(400, 'cam.invalid', `quiet_hours.${k} must be HH:MM`, 'quiet_hours');
++ }
++ args.push(JSON.stringify({ enabled: Boolean(q.enabled), start: q.start || '22:00', end: q.end || '07:00', tz }));
++ sets.push(`quiet_hours=$${args.length}::jsonb`);
++ }
++ if (!sets.length) return refusal(400, 'cam.invalid', 'Send enabled, region or quiet_hours', null);
++ args.push(clock());
++ const updated = await db.maybe(`UPDATE cam_cameras SET ${sets.join(', ')}, updated_at=$${args.length}::timestamptz WHERE id=$1 RETURNING *`, args);
++ return { ok: true, camera: wireCamera(updated) };
++ }
++
++ async function presenceOf(owner) {
++ const row = await db.maybe('SELECT * FROM cam_presence WHERE owner=$1', [owner]);
++ return row ? {
++ owner: row.owner, state: row.state, source: row.source,
++ camera_id: row.camera_id == null ? null : row.camera_id,
++ updated_at: iso(row.updated_at),
++ } : { owner, state: 'home', source: 'browser', camera_id: null, updated_at: null };
++ }
++
++ async function setPresence(owner, { state, source = 'browser', cameraId = null } = {}) {
++ if (!PRESENCE.includes(state)) return refusal(400, 'cam.invalid', 'state must be home or away', 'state');
++ if (!['browser', 'phone', 'node'].includes(source)) return refusal(400, 'cam.invalid', 'source must be browser, phone or node', 'source');
++ if (cameraId != null) {
++ const cam = await cameraById(cameraId);
++ if (!cam || cam.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
++ }
++ const row = await db.maybe(`INSERT INTO cam_presence (owner, state, source, camera_id, updated_at)
++ VALUES ($1,$2,$3,$4,$5::timestamptz)
++ ON CONFLICT (owner) DO UPDATE SET state=EXCLUDED.state, source=EXCLUDED.source, camera_id=EXCLUDED.camera_id, updated_at=EXCLUDED.updated_at
++ RETURNING *`, [owner, state, source, cameraId, clock()]);
++ return { ok: true, presence: { owner: row.owner, state: row.state, source: row.source, camera_id: row.camera_id, updated_at: iso(row.updated_at) } };
++ }
++
++ async function createEvent({ cameraId, owner, kind, at, title, detail = null, personId = null, personName = null, known = false, muted = false, away = false, priority = 'normal', stillUrl = null, clipUrl = null, clipBefore = config.timeline.clipBeforeSeconds, clipAfter = config.timeline.clipAfterSeconds, place = null, lat = null, lng = null, metadata = {}, notified = false }) {
++ if (!EVENT_KINDS.includes(kind)) return refusal(400, 'cam.invalid', `kind must be one of ${EVENT_KINDS.join(', ')}`, 'kind');
++ if (!PRIORITIES.includes(priority)) return refusal(400, 'cam.invalid', `priority must be one of ${PRIORITIES.join(', ')}`, 'priority');
++ if (stillUrl != null && !/^https?:\/\//.test(String(stillUrl))) return refusal(400, 'cam.invalid', 'still_url must be an http(s) URL', 'still_url');
++ if (clipUrl != null && !/^https?:\/\//.test(String(clipUrl))) return refusal(400, 'cam.invalid', 'clip_url must be an http(s) URL', 'clip_url');
++ const cleanTitle = cleanLine(title, 241);
++ if (!cleanTitle || cleanTitle.length > 240 || controlChars(title)) return refusal(400, 'cam.invalid', 'title is 1 to 240 characters of plain text', 'title');
++ if (lat != null && (!Number.isFinite(Number(lat)) || Math.abs(Number(lat)) > 90)) return refusal(400, 'cam.invalid', 'lat is -90 to 90', 'lat');
++ if (lng != null && (!Number.isFinite(Number(lng)) || Math.abs(Number(lng)) > 180)) return refusal(400, 'cam.invalid', 'lng is -180 to 180', 'lng');
++ const row = await db.maybe(`INSERT INTO cam_events
++ (id, camera_id, owner, kind, at, title, detail, person_id, person_name, known, muted, away, priority, still_url, clip_url,
++ clip_before_seconds, clip_after_seconds, place, lat, lng, metadata, notified, created_at)
++ VALUES ($1,$2,$3,$4,$5::timestamptz,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21::jsonb,$22,$23::timestamptz)
++ RETURNING *`,
++ [s.newId('cev'), cameraId, owner, kind, at, cleanTitle, detail == null ? null : cleanLine(detail, 500), personId, personName == null ? null : cleanLine(personName, 80),
++ Boolean(known), Boolean(muted), Boolean(away), priority, stillUrl, clipUrl, Number(clipBefore), Number(clipAfter),
++ place == null ? null : cleanLine(place, 240), lat == null ? null : Number(lat), lng == null ? null : Number(lng),
++ JSON.stringify(metadata && typeof metadata === 'object' ? metadata : {}), Boolean(notified), clock()]);
++ return { ok: true, event: wireEvent(row) };
++ }
++
++ async function eventsOf({ owner, cameraId = null, limit = 60, kind = null, since = null } = {}) {
++ const args = [owner];
++ let where = 'owner=$1';
++ if (cameraId) { args.push(cameraId); where += ` AND camera_id=$${args.length}`; }
++ if (kind) { args.push(kind); where += ` AND kind=$${args.length}`; }
++ if (since) { args.push(since); where += ` AND at >= $${args.length}::timestamptz`; }
++ args.push(Math.min(200, Math.max(1, Number(limit) || 60)));
++ const rows = await db.many(`SELECT * FROM cam_events WHERE ${where} ORDER BY at DESC, id DESC LIMIT $${args.length}`, args);
++ return rows.map(wireEvent);
++ }
++
++ async function eventById(id, owner) {
++ return wireEvent(await db.maybe('SELECT * FROM cam_events WHERE id=$1 AND owner=$2', [String(id || ''), owner]));
++ }
++
++ async function annotateEvent(id, owner, { personName, personId = null, muted } = {}) {
++ const row = await db.maybe('SELECT * FROM cam_events WHERE id=$1 AND owner=$2', [String(id || ''), owner]);
++ if (!row) return refusal(404, 'cam.not_found', 'No such event.');
++ const sets = [];
++ const args = [row.id];
++ if (personName !== undefined) {
++ const name = cleanLine(personName, 81);
++ if (!name || name.length > 80 || controlChars(personName)) return refusal(400, 'cam.invalid', 'person_name is 1 to 80 characters of plain text', 'person_name');
++ args.push(name); sets.push(`person_name=$${args.length}`);
++ }
++ if (personId !== undefined) {
++ if (personId != null && !/^flp_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(personId))) return refusal(400, 'cam.invalid', 'person_id is a Vision face id', 'person_id');
++ args.push(personId == null ? null : String(personId)); sets.push(`person_id=$${args.length}`);
++ }
++ if (muted !== undefined) { args.push(Boolean(muted)); sets.push(`muted=$${args.length}`); }
++ if (personName !== undefined || personId !== undefined) sets.push('known=true');
++ if (!sets.length) return refusal(400, 'cam.invalid', 'Send person_name, person_id or muted', null);
++ const updated = await db.maybe(`UPDATE cam_events SET ${sets.join(', ')} WHERE id=$1 RETURNING *`, args);
++ return { ok: true, event: wireEvent(updated) };
++ }
++
++ async function shareEvent({ eventId, owner, purpose = 'link', tokenHash, expiresAt }) {
++ if (!SHARE_PURPOSES.includes(purpose)) return refusal(400, 'cam.invalid', `purpose must be one of ${SHARE_PURPOSES.join(', ')}`, 'purpose');
++ const row = await db.maybe('SELECT id FROM cam_events WHERE id=$1 AND owner=$2', [eventId, owner]);
++ if (!row) return refusal(404, 'cam.not_found', 'No such event.');
++ const share = await db.maybe(`INSERT INTO cam_event_shares (id, event_id, owner, purpose, token_hash, expires_at, created_at)
++ VALUES ($1,$2,$3,$4,$5,$6::timestamptz,$7::timestamptz) RETURNING *`,
++ [s.newId('ces'), eventId, owner, purpose, tokenHash, expiresAt, clock()]);
++ return { ok: true, share: wireShare(share) };
++ }
++
++ async function eventByShare(tokenHash) {
++ const row = await db.maybe(`SELECT s.*, e.* FROM cam_event_shares s JOIN cam_events e ON e.id=s.event_id
++ WHERE s.token_hash=$1 AND s.revoked_at IS NULL AND s.expires_at > $2::timestamptz`, [String(tokenHash || ''), clock()]);
++ if (!row) return null;
++ return { event: wireEvent(row), purpose: row.purpose, expires_at: iso(row.expires_at) };
++ }
++
async function countsFor(subject) {
const row = await db.maybe(
`SELECT
@@@ -371,16 +554,18 @@ function createStore(s, { config }) {
return {
wireCamera, wireGrant, wirePhoto, wireStill,
-- cameraById, camerasOf, createCamera, patchCamera, removeCamera,
++ cameraById, camerasOf, createCamera, patchCamera, removeCamera, setFamiliarFaces, presenceOf, setPresence,
grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
createPhoto, photoById, photosOf,
createStill, stillById, stillsOf,
++ createEvent, eventsOf, eventById, annotateEvent, shareEvent, eventByShare,
countsFor,
-- SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, CAPTION_MAX, NAME_MAX, SLOT_RE, SUBJECT_RE, BLOCKED_WORDS,
++ SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, EVENT_KINDS, PRIORITIES, PRESENCE, SHARE_PURPOSES,
++ CAPTION_MAX, NAME_MAX, SLOT_RE, SUBJECT_RE, BLOCKED_WORDS,
};
}
module.exports = {
createStore, caption, toSubject, wireCamera, wireGrant, wirePhoto, wireStill,
-- SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, BLOCKED_WORDS, SLOT_RE, SUBJECT_RE,
++ SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, EVENT_KINDS, PRIORITIES, PRESENCE, SHARE_PURPOSES, BLOCKED_WORDS, SLOT_RE, SUBJECT_RE, wireEvent, wireShare,
};
ddiff --git a/server/upstreams.js b/server/upstreams.js
iindex 18a0ead..8c0304b 100644
---- a/server/upstreams.js
++++ b/server/upstreams.js
@@@ -21,7 +21,7 @@ class UpstreamError extends Error {
}
function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }) {
-- const { media, live, pics, timeoutMs } = config.upstreams;
++ const { media, live, pics, vision, timeoutMs } = config.upstreams;
const base = (u) => String(u).replace(/\/+$/, '');
const ua = () => `OpenVibe.Cam/0.1 (+${config.baseUrl})`;
@@@ -139,7 +139,72 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }
return { ok: true, subject, username: data.username || name, display_name: data.display_name || data.username || name };
}
-- return { liveStream, liveFrame, picsUpload, resolveUsername, UpstreamError, base };
++ // ── F1: the owner's own Vision face library, with the owner's own Network token ──
++ // Cam never holds a template or a crop; these calls pass the person's token and Vision decides ownership,
++ // consent and encryption. A refusal is Vision's problem code, not a silent empty match.
++ async function visionCall(path, { token, method = 'GET', json = null, timeout = Math.max(timeoutMs, 10_000) } = {}) {
++ if (!token) throw new UpstreamError('cam.vision.sign_in_required', 'the camera owner\'s Network token is needed for the face library', { status: 401 });
++ const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
++ let body = null;
++ if (json !== null) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(json); }
++ let res;
++ try {
++ res = await call(`${base(vision)}${path}`, { method, headers, body, timeout });
++ } catch (err) {
++ throw new UpstreamError('cam.vision.unavailable', 'OpenVibe.Vision did not answer', { cause: err });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const code = data && typeof data.code === 'string' && /^[a-z][a-z0-9._-]{1,80}$/.test(data.code) ? data.code : 'cam.vision.refused';
++ const detail = data && typeof data.detail === 'string' ? data.detail.slice(0, 240) : `OpenVibe.Vision answered ${res.status}`;
++ throw new UpstreamError(code, detail, { status: res.status >= 400 && res.status < 500 ? res.status : 502 });
++ }
++ return data;
++ }
++
++ const visionApi = {
++ createLibrary: ({ token, cameraIds, region, threshold }) => visionCall('/api/v1/faces/libraries', { token, method: 'POST', json: { camera_ids: cameraIds, region, ...(threshold == null ? {} : { threshold }) } }),
++ getLibrary: ({ token, libraryId }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, { token }),
++ patchLibrary: ({ token, libraryId, body }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, { token, method: 'PATCH', json: body }),
++ deleteLibrary: ({ token, libraryId }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, { token, method: 'DELETE' }),
++ enroll: ({ token, libraryId, image, name, subject }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/enroll`, {
++ token, method: 'POST', json: { image, name, ...(subject ? { subject } : {}) },
++ }),
++ match: ({ token, libraryId, image }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/match`, { token, method: 'POST', json: { image } }),
++ people: ({ token, libraryId }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people`, { token }),
++ patchPerson: ({ token, libraryId, personId, body }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}`, { token, method: 'PATCH', json: body }),
++ removePerson: ({ token, libraryId, personId }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}`, { token, method: 'DELETE' }),
++ requestConsent: ({ token, libraryId, personId }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}/request`, { token, method: 'POST' }),
++ checkConsent: ({ token, libraryId }) => visionCall(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/consent-check`, { token, method: 'POST' }),
++ };
++
++ // ── Network notifications: one per event, as Cam's own service token ──
++ const notifyTokens = config.oauth.clientSecret
++ ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: config.timeline.notificationScope, fetch: fetchImpl,
++ })
++ : null;
++
++ async function pushNotification({ subject, title, message, priority = 'normal', url = null, type = 'CAM_EVENT', category = 'cam' } = {}) {
++ if (!notifyTokens) return { sent: false, skipped: 'not_configured' };
++ let token;
++ try { token = await notifyTokens.getToken(); } catch { return { sent: false, skipped: 'token_unavailable' }; }
++ let res;
++ try {
++ res = await call(`${config.networkInternalUrl}/internal/notifications/push`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ subject, type, category, priority, service: config.timeline.notificationService, title, message, url }),
++ timeout: timeoutMs,
++ });
++ } catch { return { sent: false, skipped: 'unreachable' }; }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || data.ok !== true) return { sent: false, skipped: 'refused', status: res.status };
++ return { sent: !data.skipped, notification_id: data.notification ? data.notification.id : null };
++ }
++
++ return { liveStream, liveFrame, picsUpload, resolveUsername, vision: visionApi, pushNotification, UpstreamError, base };
}
module.exports = { createUpstreams, UpstreamError, USERNAME_RE };
ddiff --git a/test/familiar-faces.test.js b/test/familiar-faces.test.js
nnew file mode 100644
iindex 0000000..8ecfee3
---- /dev/null
++++ b/test/familiar-faces.test.js
@@@ -0,0 +1,215 @@
++'use strict';
++/**
++ * F1: familiar faces and the event timeline.
++ *
++ * The camera owner turns the setting on, names a face through Vision's library (Cam stores no template or crop),
++ * and a detection becomes one event: person/package/vehicle/animal/loiter/door, a 10 s-before / 20 s-after clip
++ * reference, a still, away-mode priority, a Network notification (held for a muted name or quiet hours), a private
++ * share link and the police export. Vision is the test fixture in test/helpers/upstreams.js.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const DATA_URL = `data:image/png;base64,${Buffer.from('not-a-real-png').toString('base64')}`;
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ try {
++ let camera = null;
++ let libraryId = null;
++ let person = null;
++ let personEvent = null;
++
++ await check('a camera pairs and Familiar faces creates the owner\'s own Vision library', async () => {
++ const r = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', source: 'phone' } });
++ assert.strictEqual(r.status, 201, r.text);
++ camera = r.json();
++ const on = await t.get(`/api/v1/cameras/${camera.id}/familiar-faces`, { as: alice, method: 'PUT', json: { enabled: true, region: 'US-IL' } });
++ assert.strictEqual(on.status, 200, on.text);
++ libraryId = on.json().library.id;
++ assert.strictEqual(on.json().enabled, true);
++ assert.strictEqual(on.json().region, 'US-IL');
++ assert.strictEqual(on.json().region_mode, 'strict');
++ assert.ok(t.upstreams.visionLibrary(libraryId), 'Vision library exists');
++ const call = t.upstreams.visionCalls().find((c) => c.method === 'POST' && c.path === '/api/v1/faces/libraries');
++ assert.ok(call, 'create call');
++ assert.deepStrictEqual(call.body.camera_ids, [camera.id]);
++ assert.match(call.auth, /^Bearer /, 'the owner\'s own token is forwarded');
++ });
++
++ await check('naming a face goes through Vision and keeps no crop in Cam', async () => {
++ const r = await t.get(`/api/v1/cameras/${camera.id}/familiar-faces/enroll`, {
++ as: alice, json: { name: 'Mom', image: DATA_URL },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ person = r.json().person;
++ assert.match(person.id, /^flp_/);
++ assert.strictEqual(person.status, 'pending_consent');
++ assert.strictEqual(r.json().camera_id, camera.id);
++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM information_schema.columns WHERE table_name='cam_cameras' AND column_name LIKE '%image%'")), 0);
++ const people = await t.get(`/api/v1/cameras/${camera.id}/familiar-faces/people`, { as: alice });
++ assert.strictEqual(people.status, 200);
++ assert.strictEqual(people.json().people.length, 1);
++ });
++
++ await check('the owner mutes a name and another person cannot read the setting', async () => {
++ const muted = await t.get(`/api/v1/cameras/${camera.id}/familiar-faces/people/${person.id}`, { as: alice, method: 'PATCH', json: { muted: true } });
++ assert.strictEqual(muted.status, 200, muted.text);
++ assert.strictEqual(muted.json().muted, true);
++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/familiar-faces`, { as: bob })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/familiar-faces/enroll`, { as: bob, json: { name: 'x', image: DATA_URL } })).status, 404);
++ });
++
++ await check('away mode says away from the phone, and a known face is one event with a 10/20 clip', async () => {
++ const away = await t.get('/api/v1/presence', { as: alice, method: 'PUT', json: { state: 'away', source: 'phone' } });
++ assert.strictEqual(away.status, 200, away.text);
++ assert.strictEqual(away.json().presence.state, 'away');
++ t.upstreams.setVisionMatch({ match: true, label: 'Mom', name: 'Mom', person_id: person.id, muted: false, score: 0.88, threshold: 0.363 });
++ const r = await t.get(`/api/v1/cameras/${camera.id}/events`, {
++ as: alice,
++ json: {
++ kind: 'person', still_url: 'https://openvibe.pics/i/still1.jpg', clip_url: 'https://openvibe.media/clip1.mp4',
++ face_image: DATA_URL, place: '12 Oak Street', lat: 41.88, lng: -87.63,
++ },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ personEvent = r.json().event;
++ assert.strictEqual(personEvent.title, 'Mom is at the Front door');
++ assert.strictEqual(personEvent.known, true);
++ assert.strictEqual(personEvent.away, true);
++ assert.strictEqual(personEvent.priority, 'normal', 'a known face is not the louder unknown-person alert');
++ assert.deepStrictEqual(personEvent.clip, { url: 'https://openvibe.media/clip1.mp4', before_seconds: 10, after_seconds: 20 });
++ assert.strictEqual(personEvent.still_url, 'https://openvibe.pics/i/still1.jpg');
++ assert.strictEqual(personEvent.location.place, '12 Oak Street');
++ assert.strictEqual(r.json().notification.sent, true, 'the Network notification went out');
++ assert.ok(t.network.requests.filter((r) => r.path === '/internal/notifications/push').length >= 1);
++ const matchCall = t.upstreams.visionCalls().filter((c) => c.path.endsWith('/match')).pop();
++ assert.ok(matchCall, 'the face crop was matched in the camera library');
++ assert.strictEqual(matchCall.body.image, DATA_URL);
++ });
++
++ await check('an unknown person while away is unknown and louder; package and loitering need no face', async () => {
++ t.upstreams.setVisionMatch({ match: false, label: 'unknown person', name: null, person_id: null, muted: false, score: 0.12, threshold: 0.363 });
++ const before = t.upstreams.visionCalls().filter((c) => c.path.endsWith('/match')).length;
++ const unknown = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'person', face_image: DATA_URL } });
++ assert.strictEqual(unknown.status, 201, unknown.text);
++ assert.strictEqual(unknown.json().event.title, 'Unknown person at the Front door');
++ assert.strictEqual(unknown.json().event.known, false);
++ assert.strictEqual(unknown.json().event.priority, 'high');
++ const pkg = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'package' } });
++ assert.strictEqual(pkg.status, 201, pkg.text);
++ assert.strictEqual(pkg.json().event.title, 'Package left');
++ const afterPackage = t.upstreams.visionCalls().filter((c) => c.path.endsWith('/match')).length;
++ assert.strictEqual(afterPackage, before + 1, 'only the person event asked Vision');
++ const loiter = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'loiter', linger_seconds: 134 } });
++ assert.strictEqual(loiter.json().event.title, 'Someone lingered 2 min');
++ const door = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'door' } });
++ assert.strictEqual(door.json().event.title, "The door opened while you're away");
++ assert.strictEqual(door.json().event.priority, 'high');
++ const car = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'vehicle' } });
++ assert.strictEqual(car.json().event.title, 'Car at the Front door');
++ const animal = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'animal' } });
++ assert.strictEqual(animal.json().event.title, 'Animal at the Front door');
++ });
++
++ await check('a muted name still matches but its notification is held', async () => {
++ t.upstreams.setVisionMatch({ match: true, label: 'Mom', name: 'Mom', person_id: person.id, muted: true, score: 0.9, threshold: 0.363 });
++ const r = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'person', face_image: DATA_URL } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().event.muted, true);
++ assert.strictEqual(r.json().notification.sent, false);
++ assert.strictEqual(r.json().notification.skipped, 'muted');
++ });
++
++ await check('quiet hours hold the notification, not the event', async () => {
++ const quiet = await t.get(`/api/v1/cameras/${camera.id}/familiar-faces`, {
++ as: alice, method: 'PUT', json: { enabled: true, quiet_hours: { enabled: true, start: '00:00', end: '23:59', tz: 'UTC' } },
++ });
++ assert.strictEqual(quiet.status, 200, quiet.text);
++ const r = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice, json: { kind: 'package' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().notification.sent, false);
++ assert.strictEqual(r.json().notification.skipped, 'quiet_hours');
++ });
++
++ await check('the timeline is the owner\'s and a viewer or another person sees none', async () => {
++ const list = await t.get(`/api/v1/cameras/${camera.id}/events`, { as: alice });
++ assert.strictEqual(list.status, 200, list.text);
++ assert.ok(list.json().count >= 6);
++ assert.strictEqual(list.json().events[0].camera_id, camera.id);
++ const all = await t.get('/api/v1/events', { as: alice });
++ assert.ok(all.json().count >= 6);
++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/events`, { as: bob })).status, 404);
++ assert.strictEqual((await t.get('/api/v1/events', { as: bob })).json().count, 0);
++ const grant = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' } });
++ assert.strictEqual(grant.status, 201, grant.text);
++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/events`, { as: bob })).status, 404);
++ const wall = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob });
++ assert.strictEqual(wall.status, 200);
++ assert.strictEqual(wall.json().camera.familiar_faces, undefined);
++ assert.strictEqual(wall.json().camera.vision_library_id, undefined);
++ });
++
++ await check('marking "this is X" changes the event label and leaves the camera library alone', async () => {
++ const r = await t.get(`/api/v1/events/${personEvent.id}`, { as: alice, method: 'PATCH', json: { person_name: 'Mom (visitor)' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().event.person_name, 'Mom (visitor)');
++ assert.strictEqual(r.json().event.known, true);
++ assert.strictEqual((await t.get(`/api/v1/events/${personEvent.id}`, { as: bob, method: 'PATCH', json: { person_name: 'x' } })).status, 404);
++ });
++
++ await check('a private share link returns the event once and a bad token is 404', async () => {
++ const r = await t.get(`/api/v1/events/${personEvent.id}/share`, { as: alice, json: { purpose: 'link', days: 2 } });
++ assert.strictEqual(r.status, 201, r.text);
++ const token = r.json().token;
++ assert.ok(token && r.json().url.includes(`/e/${token}`));
++ assert.strictEqual(r.json().share.purpose, 'link');
++ const shared = await t.get(`/api/v1/events/shared/${token}`);
++ assert.strictEqual(shared.status, 200, shared.text);
++ assert.strictEqual(shared.json().event.id, personEvent.id);
++ assert.strictEqual((await t.get('/api/v1/events/shared/not-a-real-token-000000')).status, 404);
++ });
++
++ await check('the police export is a file with time and place', async () => {
++ const r = await t.get(`/api/v1/events/${personEvent.id}/export`, { as: alice });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.headers.get('content-disposition'), /^attachment; filename="cam-event-/);
++ const file = r.json();
++ assert.strictEqual(file.kind, 'cam.event.police-export');
++ assert.strictEqual(file.event.time, personEvent.at);
++ assert.strictEqual(file.event.place.place, '12 Oak Street');
++ assert.strictEqual(file.event.clip.url, 'https://openvibe.media/clip1.mp4');
++ assert.strictEqual(file.camera.name, 'Front door');
++ const police = await t.get(`/api/v1/events/${personEvent.id}/police`, { as: alice, method: 'POST' });
++ assert.strictEqual(police.status, 200, police.text);
++ assert.strictEqual(police.json().purpose, 'police');
++ assert.match(police.json().download_url, /\/api\/v1\/events\/cev_/);
++ });
++
++ await check('ADR-033 export and erasure cover the timeline and presence', async () => {
++ const part = await t.ctx.accountData.exportPart(alice.subject);
++ const files = part.files.map((f) => f.name);
++ for (const f of ['events.json', 'event-shares.json', 'presence.json']) assert.ok(files.includes(f), f);
++ const erased = await t.ctx.accountData.erase([alice.subject]);
++ assert.ok(erased.erased.cam_events >= 1);
++ assert.ok(erased.erased.cam_presence >= 1);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_events WHERE owner=$1', [alice.subject])), 0);
++ });
++
++ await check('unpairing deletes the camera\'s Vision library', async () => {
++ const c = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Back door' } })).json();
++ const on = await t.get(`/api/v1/cameras/${c.id}/familiar-faces`, { as: alice, method: 'PUT', json: { enabled: true, region: 'US-CA' } });
++ assert.strictEqual(on.status, 200, on.text);
++ const lib = on.json().library.id;
++ assert.ok(t.upstreams.visionLibrary(lib));
++ const del = await t.get(`/api/v1/cameras/${c.id}`, { as: alice, method: 'DELETE' });
++ assert.strictEqual(del.status, 204, del.text);
++ assert.strictEqual(t.upstreams.visionLibrary(lib), undefined);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 3673172..2fad429 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -32,7 +32,7 @@ async function boot(opts = {}) {
OV_OAUTH_CLIENT_ID: 'cam', OV_OAUTH_CLIENT_SECRET: 'cam-secret', COOKIE_SECURE: 'false',
CAM_DRILL: '1',
CAM_EVENTS_URL: network.url,
-- OV_MEDIA_URL: upstreams.url, OV_LIVE_URL: upstreams.url, OV_PICS_URL: upstreams.url,
++ OV_MEDIA_URL: upstreams.url, OV_LIVE_URL: upstreams.url, OV_PICS_URL: upstreams.url, OV_VISION_URL: upstreams.url,
...(opts.env || {}),
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
iindex 08e08db..9283d7a 100644
---- a/test/helpers/upstreams.js
++++ b/test/helpers/upstreams.js
@@@ -50,6 +50,7 @@ async function startUpstreams() {
pics: { status: 201 },
uploads: [],
requests: [],
++ vision: { libraries: new Map(), match: null, calls: [], status: 200, seq: 0 },
};
const srv = await listen(async (req, raw, json, res) => {
@@@ -80,6 +81,75 @@ async function startUpstreams() {
return res.end(bytes);
}
++ // ── Vision's familiar-face library (F1), as Cam's owner-token proxy sees it ──
++ if (p.startsWith('/api/v1/faces/libraries')) {
++ const auth = String(req.headers.authorization || '');
++ state.vision.calls.push({ method: req.method, path: p, auth, body: raw.length ? (() => { try { return JSON.parse(raw.toString('utf8')); } catch { return null; } })() : null });
++ if (state.vision.status >= 400) return json(state.vision.status, { code: 'faces.test_refused', detail: 'Vision refused this test call' });
++ let body = {};
++ try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { body = {}; }
++ if (p === '/api/v1/faces/libraries' && req.method === 'POST') {
++ const id = `flb_${String(++state.vision.seq).padStart(26, '0')}`;
++ const library = {
++ id, camera_ids: body.camera_ids || [], region: body.region || 'unknown',
++ region_mode: ['US-IL', 'US-TX', 'US-WA', 'EU', 'GB', 'UK'].includes(String(body.region || '').toUpperCase()) || !body.region ? 'strict' : 'open',
++ threshold: body.threshold == null ? 0.363 : body.threshold,
++ created_at: new Date().toISOString(), updated_at: new Date().toISOString(), people: [],
++ };
++ state.vision.libraries.set(id, library);
++ return json(201, library);
++ }
++ const libraryPath = p.match(/^\/api\/v1\/faces\/libraries\/([^/]+)$/);
++ if (libraryPath) {
++ const library = state.vision.libraries.get(libraryPath[1]);
++ if (!library) return json(404, { code: 'faces.not_found', detail: 'No such face library.' });
++ if (req.method === 'DELETE') { state.vision.libraries.delete(library.id); return json(200, { deleted: true, id: library.id }); }
++ if (req.method === 'PATCH') {
++ if (body.region !== undefined) {
++ library.region = body.region;
++ library.region_mode = ['US-IL', 'US-TX', 'US-WA', 'EU', 'GB', 'UK'].includes(String(body.region).toUpperCase()) || !body.region ? 'strict' : 'open';
++ }
++ if (body.threshold !== undefined) library.threshold = body.threshold;
++ return json(200, { ...library, people: library.people });
++ }
++ return json(200, { ...library, people: library.people });
++ }
++ const enroll = p.match(/^\/api\/v1\/faces\/libraries\/([^/]+)\/enroll$/);
++ if (enroll && req.method === 'POST') {
++ const library = state.vision.libraries.get(enroll[1]);
++ if (!library) return json(404, { code: 'faces.not_found', detail: 'No such face library.' });
++ const person = {
++ id: `flp_${String(++state.vision.seq).padStart(26, '0')}`, library_id: library.id, name: String(body.name || ''),
++ subject: body.subject || null, status: library.region_mode === 'strict' ? 'pending_consent' : 'active',
++ consent_basis: library.region_mode === 'strict' ? null : null, muted: false,
++ has_template: library.region_mode !== 'strict', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), last_matched_at: null,
++ };
++ library.people.push(person);
++ return json(200, { person, consent_request: person.status === 'pending_consent' ? { token: 'req-token', url: 'https://openvibe.vision/people/privacy?face_request=req-token', expires_at: new Date(Date.now() + 86400000).toISOString(), sent: false, text_version: 'face-library-2026-10-28' } : null, enrolled: person.status === 'active' });
++ }
++ const match = p.match(/^\/api\/v1\/faces\/libraries\/([^/]+)\/match$/);
++ if (match && req.method === 'POST') {
++ const library = state.vision.libraries.get(match[1]);
++ if (!library) return json(404, { code: 'faces.not_found', detail: 'No such face library.' });
++ if (state.vision.match) return json(200, { library_id: library.id, ...state.vision.match });
++ return json(200, { library_id: library.id, match: false, label: 'unknown person', name: null, person_id: null, muted: false, score: null, threshold: library.threshold });
++ }
++ const personMatch = p.match(/^\/api\/v1\/faces\/libraries\/([^/]+)\/people(?:\/([^/]+)(\/request)?)?$/);
++ if (personMatch) {
++ const library = state.vision.libraries.get(personMatch[1]);
++ if (!library) return json(404, { code: 'faces.not_found', detail: 'No such face library.' });
++ if (!personMatch[2]) return json(200, { library: { ...library, people: library.people }, people: library.people });
++ const person = library.people.find((x) => x.id === personMatch[2]);
++ if (!person) return json(404, { code: 'faces.not_found', detail: 'No such named face.' });
++ if (personMatch[3]) return json(200, { token: 'req-token', url: 'https://openvibe.vision/people/privacy?face_request=req-token', expires_at: new Date(Date.now() + 86400000).toISOString(), sent: false, text_version: 'face-library-2026-10-28' });
++ if (req.method === 'DELETE') { library.people = library.people.filter((x) => x.id !== person.id); return json(200, { deleted: true, id: person.id, name: person.name, templates: 1, crops: 1, requests: 0 }); }
++ if (req.method === 'PATCH') { if (body.name !== undefined) person.name = body.name; if (body.muted !== undefined) person.muted = Boolean(body.muted); return json(200, person); }
++ return json(200, person);
++ }
++ if (/^\/api\/v1\/faces\/libraries\/[^/]+\/consent-check$/.test(p) && req.method === 'POST') return json(200, { checked: 0, activated: [] });
++ return json(404, { code: 'faces.not_found', detail: 'No such face route.' });
++ }
++
if (p === '/api/v1/images' && req.method === 'POST') {
const status = state.pics.status || 201;
const auth = String(req.headers.authorization || '');
@@@ -111,6 +181,10 @@ async function startUpstreams() {
setFrame(slot, conf) { state.frames[slot] = conf; },
setPics(status) { state.pics.status = status; },
uploads() { return state.uploads; },
++ setVisionMatch(match) { state.vision.match = match; },
++ setVisionStatus(status) { state.vision.status = status; },
++ visionCalls() { return state.vision.calls; },
++ visionLibrary(id) { return state.vision.libraries.get(id); },
};
}