openvibers/openvibe.cam Commits
F1: familiar faces, event timeline and police export (#22)
Changes
@@@ -34,6 +34,14 @@ CAM_EVENTS_ENDPOINT=https://openvibe.cam/internal/eventsCAM_EVENTS_INTERVAL_MS=5000CAM_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.mediaOV_LIVE_URL=https://openvibe.live@@@ -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`,@@@ -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",nnew file mode 100644@@@ -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);@@@ -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');@@@ -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) },nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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' },@@@ -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,};@@@ -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 };nnew file mode 100644@@@ -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();++})();@@@ -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];@@@ -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); },};}
@@ -34,6 +34,14 @@ CAM_EVENTS_ENDPOINT=https://openvibe.cam/internal/events
@@ -34,6 +34,14 @@ CAM_EVENTS_ENDPOINT=https://openvibe.cam/internal/events
CAM_EVENTS_INTERVAL_MS=5000
CAM_EVENTS_INTERVAL_MS=5000
CAM_EVENTS_SCOPE=events.subscription.manage
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 ──
# ── The existing public APIs Cam uses ──
OV_MEDIA_URL=https://openvibe.media
OV_MEDIA_URL=https://openvibe.media
OV_LIVE_URL=https://openvibe.live
OV_LIVE_URL=https://openvibe.live
@@ -97,6 +97,22 @@ per-actor minute/hour limits (`CAM_LIMITS_MINUTE`, `CAM_LIMITS_HOUR`).
@@ -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 |
| `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` | your stills |
| `GET /stills/:id` | an unlisted still by its link; a private one only for its owner |
| `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:
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`,
"cam.blocked_words" }`. Other codes: `cam.sign_in_required`, `cam.person_required`, `cam.cross_site`,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"stage": "product",
"stage": "product",
"code": true,
"code": true,
"wave": "W21",
"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",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5160",
"port": "5160",
"domain": "openvibe.cam",
"domain": "openvibe.cam",
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"openvibe-sdk v0.74.0",
"openvibe-sdk v0.74.0",
"openvibe-shared v4.92.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": [
"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",
"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>",
"The owner's camera page: rename, source, status, slot and note; unpair; the private wall at /w/<id>",
@@ -25,6 +25,9 @@
@@ -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 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",
"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",
"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",
"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",
"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",
"Account export and deletion over cameras, grants given and received, photos and stills",
@@ -32,13 +35,16 @@
@@ -32,13 +35,16 @@
],
],
"notYet": [
"notYet": [
"The public camera directory, map, categories and follow-a-cam: v2; v1 makes nothing public and queues no Search document",
"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",
"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",
"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",
"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 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"
"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,
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"serviceManifestRegistered": false,
"deployed": false,
"deployed": false,
@@ -50,6 +56,9 @@
@@ -50,6 +56,9 @@
"private wall",
"private wall",
"viewer grants",
"viewer grants",
"grant revocation",
"grant revocation",
+ "familiar faces (Vision library proxy)",
+ "event timeline, away mode and notifications",
+ "private event links and police export",
"Live cam button stills",
"Live cam button stills",
"account export and deletion",
"account export and deletion",
"Network sign-in",
"Network sign-in",
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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);
@@ -32,6 +32,7 @@ const { raw } = require('./render/html');
@@ -32,6 +32,7 @@ const { raw } = require('./render/html');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createStore: createCamStore } = require('./store');
const { createStore: createCamStore } = require('./store');
const { createUpstreams } = require('./upstreams');
const { createUpstreams } = require('./upstreams');
+const { createEventPipeline } = require('./events/pipeline');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -55,6 +56,7 @@ async function createApp(opts = {}) {
@@ -55,6 +56,7 @@ async function createApp(opts = {}) {
const cameras = createCamStore(s, { config });
const cameras = createCamStore(s, { config });
const upstreams = createUpstreams({ config, fetchImpl, log });
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
// 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.
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@ -63,7 +65,7 @@ async function createApp(opts = {}) {
@@ -63,7 +65,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Cam cannot answer account events'); });
: 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -67,13 +67,27 @@ function load(env = process.env) {
@@ -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.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.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.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: {
upstreams: {
media: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
media: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
pics: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
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)),
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:
// 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.
// 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) },
photos: { maxBytes: size(env.CAM_PHOTO_MAX_BYTES, 12 * 1024 * 1024) },
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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 };
@@ -23,6 +23,7 @@
@@ -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
* 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.
* 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 express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -31,7 +32,7 @@ const { pairCamera, resolveViewer } = require('../cameras');
@@ -31,7 +32,7 @@ const { pairCamera, resolveViewer } = require('../cameras');
const { frameUrl } = require('../views');
const { frameUrl } = require('../views');
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, cameras, upstreams } = ctx;
+ const { config, s, principal, cameras, upstreams, events } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -89,7 +90,13 @@ function createApi(ctx) {
@@ -89,7 +90,13 @@ function createApi(ctx) {
// A JSON body everywhere except the photo upload, whose body is the picture itself.
// A JSON body everywhere except the photo upload, whose body is the picture itself.
const jsonBody = express.json({ limit: '64kb' });
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 ─────────────────────────────────────────────
// ── Cameras ─────────────────────────────────────────────
r.get('/cameras', requirePerson, async (req, res) => {
r.get('/cameras', requirePerson, async (req, res) => {
@@ -131,6 +138,12 @@ function createApi(ctx) {
@@ -131,6 +138,12 @@ function createApi(ctx) {
r.delete('/cameras/:id', requirePerson, async (req, res) => {
r.delete('/cameras/:id', requirePerson, async (req, res) => {
const row = await ownCamera(req, res);
const row = await ownCamera(req, res);
if (!row) return undefined;
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);
const out = await cameras.removeCamera(row.id, req.principal.subject);
if (!out.ok) return problem(res, out.status, out.code, out.message);
if (!out.ok) return problem(res, out.status, out.code, out.message);
return res.status(204).end();
return res.status(204).end();
@@ -140,7 +153,10 @@ function createApi(ctx) {
@@ -140,7 +153,10 @@ function createApi(ctx) {
const wall = await cameras.wall(String(req.params.id || ''), req.principal.subject);
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.');
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 }) };
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({
return res.json({
camera,
camera,
owner: wall.owner,
owner: wall.owner,
@@ -150,6 +166,278 @@ function createApi(ctx) {
@@ -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 ───────────────────────────────────────
// ── Viewer grants ───────────────────────────────────────
r.get('/cameras/:id/grants', requirePerson, async (req, res) => {
r.get('/cameras/:id/grants', requirePerson, async (req, res) => {
const out = await cameras.grantsOf(String(req.params.id || ''), req.principal.subject);
const out = await cameras.grantsOf(String(req.params.id || ''), req.principal.subject);
@@ -12,6 +12,10 @@
@@ -12,6 +12,10 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
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_cameras', subject: 'owner', file: 'cameras.json', erase: 'delete' },
{ table: 'cam_grants', subject: 'owner', file: 'grants-given.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' },
{ table: 'cam_grants', subject: 'viewer_subject', file: 'grants-received.json', erase: 'delete' },
@@ -11,6 +11,10 @@ const SOURCES = Object.freeze(['browser', 'phone', 'pi', 'ip']);
@@ -11,6 +11,10 @@ const SOURCES = Object.freeze(['browser', 'phone', 'pi', 'ip']);
const STATUSES = Object.freeze(['paired', 'live', 'offline']);
const STATUSES = Object.freeze(['paired', 'live', 'offline']);
const FILTERS = Object.freeze(['none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur']);
const FILTERS = Object.freeze(['none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur']);
const STILL_VISIBILITIES = Object.freeze(['unlisted', 'private']);
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 PHOTO_VISIBILITIES = Object.freeze(['private', 'unlisted']);
const CAPTION_MAX = 280;
const CAPTION_MAX = 280;
const NAME_MAX = 80;
const NAME_MAX = 80;
@@ -61,6 +65,11 @@ function wireCamera(row) {
@@ -61,6 +65,11 @@ function wireCamera(row) {
slot: row.slot == null ? null : row.slot,
slot: row.slot == null ? null : row.slot,
notes: row.notes == null ? null : row.notes,
notes: row.notes == null ? null : row.notes,
owner_username: row.owner_username == null ? null : row.owner_username,
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),
paired_at: iso(row.paired_at),
last_seen_at: iso(row.last_seen_at),
last_seen_at: iso(row.last_seen_at),
created_at: iso(row.created_at),
created_at: iso(row.created_at),
@@ -85,6 +94,54 @@ function wireGrant(row, { now = Date.now() } = {}) {
@@ -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 } = {}) {
function wirePhoto(row, { owner = false } = {}) {
if (!row) return null;
if (!row) return null;
return {
return {
@@ -358,6 +415,132 @@ function createStore(s, { config }) {
@@ -358,6 +415,132 @@ function createStore(s, { config }) {
return rows.map((r) => wireStill(r, { owner: true }));
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) {
async function countsFor(subject) {
const row = await db.maybe(
const row = await db.maybe(
`SELECT
`SELECT
@@ -371,16 +554,18 @@ function createStore(s, { config }) {
@@ -371,16 +554,18 @@ function createStore(s, { config }) {
return {
return {
wireCamera, wireGrant, wirePhoto, wireStill,
wireCamera, wireGrant, wirePhoto, wireStill,
- cameraById, camerasOf, createCamera, patchCamera, removeCamera,
+ cameraById, camerasOf, createCamera, patchCamera, removeCamera, setFamiliarFaces, presenceOf, setPresence,
grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
createPhoto, photoById, photosOf,
createPhoto, photoById, photosOf,
createStill, stillById, stillsOf,
createStill, stillById, stillsOf,
+ createEvent, eventsOf, eventById, annotateEvent, shareEvent, eventByShare,
countsFor,
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 = {
module.exports = {
createStore, caption, toSubject, wireCamera, wireGrant, wirePhoto, wireStill,
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,
};
};
@@ -21,7 +21,7 @@ class UpstreamError extends Error {
@@ -21,7 +21,7 @@ class UpstreamError extends Error {
}
}
function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }) {
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 base = (u) => String(u).replace(/\/+$/, '');
const ua = () => `OpenVibe.Cam/0.1 (+${config.baseUrl})`;
const ua = () => `OpenVibe.Cam/0.1 (+${config.baseUrl})`;
@@ -139,7 +139,72 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }
@@ -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 { 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 };
module.exports = { createUpstreams, UpstreamError, USERNAME_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,215 @@
@@ -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();
+})();
@@ -32,7 +32,7 @@ async function boot(opts = {}) {
@@ -32,7 +32,7 @@ async function boot(opts = {}) {
OV_OAUTH_CLIENT_ID: 'cam', OV_OAUTH_CLIENT_SECRET: 'cam-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'cam', OV_OAUTH_CLIENT_SECRET: 'cam-secret', COOKIE_SECURE: 'false',
CAM_DRILL: '1',
CAM_DRILL: '1',
CAM_EVENTS_URL: network.url,
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 || {}),
...(opts.env || {}),
};
};
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
@@ -50,6 +50,7 @@ async function startUpstreams() {
@@ -50,6 +50,7 @@ async function startUpstreams() {
pics: { status: 201 },
pics: { status: 201 },
uploads: [],
uploads: [],
requests: [],
requests: [],
+ vision: { libraries: new Map(), match: null, calls: [], status: 200, seq: 0 },
};
};
const srv = await listen(async (req, raw, json, res) => {
const srv = await listen(async (req, raw, json, res) => {
@@ -80,6 +81,75 @@ async function startUpstreams() {
@@ -80,6 +81,75 @@ async function startUpstreams() {
return res.end(bytes);
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') {
if (p === '/api/v1/images' && req.method === 'POST') {
const status = state.pics.status || 201;
const status = state.pics.status || 201;
const auth = String(req.headers.authorization || '');
const auth = String(req.headers.authorization || '');
@@ -111,6 +181,10 @@ async function startUpstreams() {
@@ -111,6 +181,10 @@ async function startUpstreams() {
setFrame(slot, conf) { state.frames[slot] = conf; },
setFrame(slot, conf) { state.frames[slot] = conf; },
setPics(status) { state.pics.status = status; },
setPics(status) { state.pics.status = status; },
uploads() { return state.uploads; },
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); },
};
};
}
}