Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
94
server/migrations/002_auth.sql
Normal file
94
server/migrations/002_auth.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Migration 002: who is allowed to ask, and what a fresh PC is told.
|
||||
--
|
||||
-- 001 built the data. Nothing could read it: the only writer was the MQTT
|
||||
-- consumer, authenticated by the broker. This adds the request/response half —
|
||||
-- staff logging in from the desktop app, and a newly installed store PC
|
||||
-- collecting its own broker credentials.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================ sessions =====
|
||||
|
||||
-- Opaque tokens in a table, not JWTs.
|
||||
--
|
||||
-- A JWT cannot be revoked without a blocklist, which is a session table with
|
||||
-- extra steps and worse failure modes. This system holds biometric data on
|
||||
-- shop-floor PCs that get lost, resold and shared between staff, so "log that
|
||||
-- device out, now" has to actually work. At this volume the lookup is one
|
||||
-- indexed read.
|
||||
--
|
||||
-- Only the SHA-256 of each token is stored. A database dump then contains no
|
||||
-- usable session — and SHA-256 rather than bcrypt because the token is 256
|
||||
-- bits from crypto/rand, so there is no dictionary to slow an attacker down
|
||||
-- through, only a per-request cost to pay.
|
||||
CREATE TABLE sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
-- Denormalised from app_users so the auth lookup is one row, and so a
|
||||
-- session cannot outlive a user being moved between clients.
|
||||
client_id uuid REFERENCES clients(id) ON DELETE CASCADE,
|
||||
access_hash bytea NOT NULL UNIQUE,
|
||||
refresh_hash bytea NOT NULL UNIQUE,
|
||||
access_expires_at timestamptz NOT NULL,
|
||||
refresh_expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
-- Enough to tell one device from another in a session list. Not an
|
||||
-- identifier, and deliberately not an IP address: a shop's IP tells us
|
||||
-- where a customer's staff live, which we have no reason to keep.
|
||||
device text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz
|
||||
);
|
||||
CREATE INDEX sessions_user_idx ON sessions (user_id) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX sessions_expiry_idx ON sessions (refresh_expires_at);
|
||||
|
||||
-- ============================================================ enrolment ====
|
||||
|
||||
-- A one-shot token that turns an anonymous install into a known site.
|
||||
--
|
||||
-- The installer ships with no credentials at all, so a leaked build hands out
|
||||
-- nothing. The operator types this code once; the server answers with the
|
||||
-- broker credentials for exactly one site and marks the token spent.
|
||||
CREATE TABLE site_enrolment_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
label text NOT NULL DEFAULT '',
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX site_enrolment_site_idx ON site_enrolment_tokens (site_id);
|
||||
|
||||
-- The broker password this site publishes with, encrypted with the server's
|
||||
-- own key (AES-GCM, BEHAVISION_SECRET_KEY).
|
||||
--
|
||||
-- It has to be recoverable, not hashed: enrolment HANDS IT OUT. Encrypting it
|
||||
-- means a stolen database dump is not a set of live broker logins, which a
|
||||
-- plaintext column would be. Mosquitto keeps its own hashed copy in its passwd
|
||||
-- file; this is the second half of a pair that must be provisioned together.
|
||||
ALTER TABLE agents ADD COLUMN mqtt_password_enc bytea;
|
||||
|
||||
-- ============================================================ health =======
|
||||
|
||||
-- Reported by the heartbeat, kept on the agent row because they describe the
|
||||
-- site right now, not a history worth querying.
|
||||
--
|
||||
-- fraction_below_gate is the one that matters: the share of faces this site's
|
||||
-- cameras saw that fell under the enrolment gate. A footfall number from a
|
||||
-- badly placed camera is wrong in a way nobody can see from the number itself,
|
||||
-- so the figure has to travel with its own confidence. Measured on the Office1
|
||||
-- camera it was 0.727 — 73% of visitors seen and discarded, while the report
|
||||
-- said what looked like a quiet week.
|
||||
ALTER TABLE agents ADD COLUMN fraction_below_gate real;
|
||||
ALTER TABLE agents ADD COLUMN cameras_total integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE agents ADD COLUMN cameras_up integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE agents ADD COLUMN spool_queued integer NOT NULL DEFAULT 0;
|
||||
-- Events a site lost because it was offline long enough to overflow its own
|
||||
-- queue. Cumulative and never reset: a footfall figure with known-lost events
|
||||
-- behind it must say so.
|
||||
ALTER TABLE agents ADD COLUMN spool_dropped bigint NOT NULL DEFAULT 0;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user