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
95 lines
4.7 KiB
PL/PgSQL
95 lines
4.7 KiB
PL/PgSQL
-- 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;
|