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:
256
server/migrations/001_initial.sql
Normal file
256
server/migrations/001_initial.sql
Normal file
@@ -0,0 +1,256 @@
|
||||
-- Behavision server schema, migration 001.
|
||||
--
|
||||
-- Two rules run through all of it:
|
||||
--
|
||||
-- 1. `client_id` is on every table, even where a join could derive it. That is
|
||||
-- what makes row-level security possible later, and it means a cross-tenant
|
||||
-- leak requires a deliberately wrong WHERE clause rather than a forgotten
|
||||
-- join condition.
|
||||
--
|
||||
-- 2. Face embeddings are biometric personal data under GDPR and India's DPDP.
|
||||
-- Template inversion is an established attack — published results
|
||||
-- reconstruct a recognisable face from an ArcFace embedding — so the
|
||||
-- "it's just numbers" defence does not hold. They live in their own table,
|
||||
-- are hard-deleted rather than soft-deleted, and every read of them is
|
||||
-- expected to be tenant-scoped.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ============================================================ tenancy ======
|
||||
|
||||
CREATE TABLE clients (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE, -- used in MQTT topics
|
||||
name text NOT NULL,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT clients_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$')
|
||||
);
|
||||
COMMENT ON COLUMN clients.slug IS
|
||||
'Appears in MQTT topics as bv/<client.slug>.<site.slug>/... and is enforced '
|
||||
'by the broker ACL, so it must stay stable and URL/topic safe.';
|
||||
|
||||
CREATE TABLE sites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
timezone text NOT NULL DEFAULT 'UTC', -- footfall is reported in local time
|
||||
address text NOT NULL DEFAULT '',
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (client_id, slug),
|
||||
CONSTRAINT sites_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$')
|
||||
);
|
||||
|
||||
-- One row per store PC. Exists so that "this site reported nothing" can be
|
||||
-- distinguished from "this site is switched off" — without it, an agent that
|
||||
-- has been unplugged for a week looks identical to a shop with no customers,
|
||||
-- which is a silent hole in the customer's own report.
|
||||
CREATE TABLE agents (
|
||||
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,
|
||||
mqtt_username text NOT NULL UNIQUE, -- '<client.slug>.<site.slug>'
|
||||
agent_version text NOT NULL DEFAULT '',
|
||||
engine_version text NOT NULL DEFAULT '',
|
||||
-- Which encoder actually loaded. Embeddings from different models are
|
||||
-- numerically incompatible, so this decides whether a site's vectors can
|
||||
-- be compared with anything else at all.
|
||||
recognition_model text NOT NULL DEFAULT '',
|
||||
last_heartbeat_at timestamptz,
|
||||
last_event_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (site_id)
|
||||
);
|
||||
CREATE INDEX agents_heartbeat_idx ON agents (last_heartbeat_at);
|
||||
|
||||
-- ============================================================ people =======
|
||||
|
||||
-- A person, scoped to ONE client.
|
||||
--
|
||||
-- Deliberately not global. Linking the same face across unrelated clients
|
||||
-- would build a cross-company biometric tracking network: legally
|
||||
-- indefensible in every jurisdiction that matters, and commercially dead on
|
||||
-- arrival — no retailer accepts their customer data enriching a competitor's.
|
||||
-- Within one client, sites DO share, which is the feature: a customer
|
||||
-- recognised at the Chennai store is the same record at the Bangalore store.
|
||||
CREATE TABLE visitors (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
label text NOT NULL DEFAULT '', -- 'Visitor 12' until staff name them
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz,
|
||||
visit_count integer NOT NULL DEFAULT 0,
|
||||
-- Erasure request received. The embeddings are removed outright (see
|
||||
-- below); this row is kept only long enough to stop the same face being
|
||||
-- re-enrolled, and is purged by retention.
|
||||
deleted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX visitors_client_idx ON visitors (client_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX visitors_last_seen_idx ON visitors (client_id, last_seen_at DESC);
|
||||
|
||||
-- Biometric templates. The most sensitive table in the system.
|
||||
--
|
||||
-- ON DELETE CASCADE from visitors is doing real work: a GDPR/DPDP erasure
|
||||
-- request must actually destroy the template, not flag it. There is no
|
||||
-- soft-delete column here on purpose.
|
||||
CREATE TABLE visitor_embeddings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
visitor_id uuid NOT NULL REFERENCES visitors(id) ON DELETE CASCADE,
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
-- Same rule as the edge: vectors from different encoders occupy different
|
||||
-- spaces and must never be compared. Every query filters on this.
|
||||
model text NOT NULL,
|
||||
embedding vector(512) NOT NULL,
|
||||
quality real NOT NULL DEFAULT 0,
|
||||
source_site_id uuid REFERENCES sites(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX visitor_embeddings_lookup_idx
|
||||
ON visitor_embeddings (client_id, model);
|
||||
CREATE INDEX visitor_embeddings_visitor_idx
|
||||
ON visitor_embeddings (visitor_id);
|
||||
-- No ANN (HNSW/IVFFlat) index yet, deliberately. Every search here MUST be
|
||||
-- filtered by client_id, and pgvector's ANN indexes under-return when combined
|
||||
-- with a selective filter — it walks the graph globally and then discards
|
||||
-- other tenants' neighbours, so a client with few vectors can silently get
|
||||
-- zero results. Exact search is microseconds at this scale (measured on the
|
||||
-- edge: 100k vectors, 21.9 ms). Add an index when measurement says to, not
|
||||
-- before.
|
||||
|
||||
-- What staff collect on the mobile form. Separate from `visitors` because it
|
||||
-- is ordinary PII with a different lifecycle and a different access rule:
|
||||
-- plenty of people should read a customer's name who should never touch a
|
||||
-- biometric template.
|
||||
CREATE TABLE visitor_profiles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
visitor_id uuid NOT NULL REFERENCES visitors(id) ON DELETE CASCADE,
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
full_name text NOT NULL DEFAULT '',
|
||||
phone text NOT NULL DEFAULT '',
|
||||
email text NOT NULL DEFAULT '',
|
||||
gender text NOT NULL DEFAULT '',
|
||||
date_of_birth date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
collected_by uuid, -- app_users.id, set by the API
|
||||
collected_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (visitor_id)
|
||||
);
|
||||
CREATE INDEX visitor_profiles_phone_idx ON visitor_profiles (client_id, phone)
|
||||
WHERE phone <> '';
|
||||
|
||||
-- Consent is a record, not a flag: "when, how, and for what" is what an
|
||||
-- auditor asks for, and a boolean cannot answer it. Revocation is a second
|
||||
-- timestamp rather than a delete, so the withdrawal itself stays provable.
|
||||
CREATE TABLE consents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
visitor_id uuid NOT NULL REFERENCES visitors(id) ON DELETE CASCADE,
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
scope text NOT NULL, -- 'biometric' | 'marketing' | ...
|
||||
method text NOT NULL, -- 'in_store_form' | 'signage' | ...
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz,
|
||||
collected_by uuid,
|
||||
evidence jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE INDEX consents_visitor_idx ON consents (visitor_id, scope);
|
||||
|
||||
-- ============================================================ activity =====
|
||||
|
||||
CREATE TABLE visits (
|
||||
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,
|
||||
visitor_id uuid REFERENCES visitors(id) ON DELETE CASCADE,
|
||||
-- The agent's own event id. MQTT delivery is at-least-once by design, so
|
||||
-- the consumer MUST be idempotent: a redelivered event after a reconnect
|
||||
-- would otherwise double a store's footfall, which is the one number the
|
||||
-- customer is paying for.
|
||||
source_event_id text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
camera_id text NOT NULL DEFAULT '',
|
||||
is_new_visitor boolean NOT NULL DEFAULT false,
|
||||
similarity real,
|
||||
quality real,
|
||||
-- gender/age/emotion. jsonb because the estimators change and their
|
||||
-- outputs differ (integer age vs bucketed range).
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- The object key, NOT a URL. A stored public URL is permanent and
|
||||
-- unrevocable; a key is presigned on read, expires, and can be deleted.
|
||||
image_key text NOT NULL DEFAULT '',
|
||||
UNIQUE (client_id, source_event_id)
|
||||
);
|
||||
CREATE INDEX visits_site_time_idx ON visits (site_id, occurred_at DESC);
|
||||
CREATE INDEX visits_client_time_idx ON visits (client_id, occurred_at DESC);
|
||||
CREATE INDEX visits_visitor_idx ON visits (visitor_id, occurred_at DESC);
|
||||
|
||||
-- Purchases are decoupled from how they were entered. Manual entry on the
|
||||
-- mobile form today, a POS integration later, same table — `source` is the
|
||||
-- only thing that differs, so adding POS is not a migration.
|
||||
CREATE TABLE purchases (
|
||||
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,
|
||||
visitor_id uuid REFERENCES visitors(id) ON DELETE SET NULL,
|
||||
visit_id uuid REFERENCES visits(id) ON DELETE SET NULL,
|
||||
-- numeric, never float: money in a float loses cents, and this feeds the
|
||||
-- conversion report the customer judges the product by.
|
||||
amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency char(3) NOT NULL DEFAULT 'INR',
|
||||
items jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
source text NOT NULL DEFAULT 'manual', -- 'manual' | 'pos' | 'import'
|
||||
external_ref text NOT NULL DEFAULT '',
|
||||
recorded_by uuid,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX purchases_site_time_idx ON purchases (site_id, occurred_at DESC);
|
||||
CREATE INDEX purchases_visitor_idx ON purchases (visitor_id);
|
||||
|
||||
-- ============================================================ access =======
|
||||
|
||||
CREATE TABLE app_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid REFERENCES clients(id) ON DELETE CASCADE, -- NULL = platform admin
|
||||
email text NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
full_name text NOT NULL DEFAULT '',
|
||||
role text NOT NULL DEFAULT 'staff', -- 'owner'|'manager'|'staff'|'admin'
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
last_login_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT app_users_role_known
|
||||
CHECK (role IN ('owner', 'manager', 'staff', 'admin'))
|
||||
);
|
||||
-- Case-insensitive uniqueness, per client. Two clients may each have a
|
||||
-- user with the same address; one client may not have two. coalesce() is
|
||||
-- needed because a NULL client_id (platform admin) does not compare equal
|
||||
-- to itself in a unique index, which would let duplicates through.
|
||||
CREATE UNIQUE INDEX app_users_email_idx
|
||||
ON app_users (coalesce(client_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
lower(email));
|
||||
|
||||
-- Every read of a biometric template and every export is worth a row here.
|
||||
-- If a client ever asks "who looked at my customers", an audit trail is the
|
||||
-- only answer that is not a guess.
|
||||
CREATE TABLE audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
client_id uuid REFERENCES clients(id) ON DELETE SET NULL,
|
||||
actor_id uuid,
|
||||
actor_kind text NOT NULL DEFAULT 'user', -- 'user' | 'agent' | 'system'
|
||||
action text NOT NULL,
|
||||
entity text NOT NULL DEFAULT '',
|
||||
entity_id text NOT NULL DEFAULT '',
|
||||
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX audit_log_client_time_idx ON audit_log (client_id, at DESC);
|
||||
|
||||
COMMIT;
|
||||
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;
|
||||
42
server/migrations/003_images.sql
Normal file
42
server/migrations/003_images.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Migration 003: face images, and the credential a shop PC uses to upload one.
|
||||
--
|
||||
-- Until now the system stored no images anywhere, which was a deliberate
|
||||
-- privacy position rather than a missing feature. Turning images on changes
|
||||
-- that position, so the schema makes the new obligations explicit rather than
|
||||
-- leaving them to whoever writes the next query:
|
||||
--
|
||||
-- * an image is an object KEY, never a URL - a stored URL is permanent and
|
||||
-- unrevocable, and these are pictures of customers' faces
|
||||
-- * erasure has to delete the object, so the keys must stay findable
|
||||
-- * a site uploads through a short-lived presigned URL and never holds
|
||||
-- bucket credentials
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- The credential a store PC uses for HTTPS calls it makes on its own behalf -
|
||||
-- today, asking for an upload URL.
|
||||
--
|
||||
-- Separate from the broker password because they authenticate different
|
||||
-- things: the broker password says "this site may publish events", this says
|
||||
-- "this site may ask the API for something". Reusing one secret for both means
|
||||
-- rotating either one breaks the other.
|
||||
--
|
||||
-- Hashed, not encrypted: unlike the broker password this is never handed back
|
||||
-- out. It is shown once at enrolment and the agent keeps it.
|
||||
ALTER TABLE agents ADD COLUMN api_token_hash bytea;
|
||||
CREATE UNIQUE INDEX agents_api_token_idx
|
||||
ON agents (api_token_hash) WHERE api_token_hash IS NOT NULL;
|
||||
|
||||
-- When the image for this visit was erased, and by which request. Kept as a
|
||||
-- record rather than just blanking image_key: "we deleted it" is the thing an
|
||||
-- auditor asks to see, and an empty column cannot tell you whether an image was
|
||||
-- deleted or never captured.
|
||||
ALTER TABLE visits ADD COLUMN image_deleted_at timestamptz;
|
||||
|
||||
-- Finding every object belonging to one person is the erasure path's first
|
||||
-- step, and without an index it is a full scan of every visit the client has
|
||||
-- ever recorded.
|
||||
CREATE INDEX visits_image_key_idx ON visits (visitor_id)
|
||||
WHERE image_key <> '' AND image_deleted_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
33
server/migrations/004_visit_sequence.sql
Normal file
33
server/migrations/004_visit_sequence.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
BEGIN;
|
||||
|
||||
-- A monotonic, server-assigned position for every visit, so a live feed can be
|
||||
-- paged without losing anyone.
|
||||
--
|
||||
-- The arrivals feed originally ordered by (occurred_at, id). That is wrong in a
|
||||
-- way that only appears under the exact condition the feed exists for: several
|
||||
-- people walking through one door together share an occurred_at to the
|
||||
-- microsecond, so the tiebreaker was a RANDOM uuid. A visit committed after the
|
||||
-- reader had moved its cursor, but carrying a lower uuid, sorted behind the
|
||||
-- cursor and was never delivered - a silent footfall undercount, exactly the
|
||||
-- class of bug this system is otherwise careful about. Measured live: four
|
||||
-- simultaneous visits, two delivered.
|
||||
--
|
||||
-- occurred_at cannot fix it either. It is the CAMERA's clock, and a site that
|
||||
-- was offline for a day floods in with yesterday's timestamps; a reader whose
|
||||
-- cursor is already past them would skip the entire backlog.
|
||||
--
|
||||
-- So the feed is ordered by when the SERVER learned of a visit, not by when it
|
||||
-- happened. Each row still carries occurred_at for display; seq is only ever a
|
||||
-- position. That is what makes a reconnecting site's backlog get delivered
|
||||
-- rather than hidden behind a timestamp the reader has passed.
|
||||
ALTER TABLE visits ADD COLUMN IF NOT EXISTS seq bigserial;
|
||||
|
||||
-- The feed always filters by tenant and orders by seq, so this is the index it
|
||||
-- runs on. Without it every poll is a scan of the whole table.
|
||||
CREATE INDEX IF NOT EXISTS visits_client_seq_idx ON visits (client_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS visits_site_seq_idx ON visits (site_id, seq);
|
||||
|
||||
-- Not UNIQUE by accident: bigserial already guarantees it, and the constraint
|
||||
-- would make a future partitioning change harder for no gain.
|
||||
|
||||
COMMIT;
|
||||
76
server/migrations/005_cameras.sql
Normal file
76
server/migrations/005_cameras.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
BEGIN;
|
||||
|
||||
-- Cameras, owned by head office rather than by the PC they run on.
|
||||
--
|
||||
-- Until now a camera existed only in `cameras.json` on one shop's disk, added
|
||||
-- through the desktop app by somebody standing in that shop. That is fine for
|
||||
-- the shop and impossible for the tenant: an owner onboarding a new store, or
|
||||
-- fixing a camera in a branch they are not standing in, had no way to do it.
|
||||
--
|
||||
-- The shop PC stays the thing that CONNECTS to the camera - it is on the same
|
||||
-- LAN, and nothing else can be - so this table is desired state that the agent
|
||||
-- pulls and applies. The engine's own store remains the running config; these
|
||||
-- two are reconciled, not merged.
|
||||
CREATE TABLE site_cameras (
|
||||
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,
|
||||
|
||||
-- The id the ENGINE knows this camera by, and the one that lands in
|
||||
-- visits.camera_id. Stable for the life of the camera: renaming it would
|
||||
-- orphan every visit already recorded against the old name.
|
||||
camera_id text NOT NULL,
|
||||
label text NOT NULL DEFAULT '',
|
||||
|
||||
-- Connection, split into parts rather than stored as one URL. The engine
|
||||
-- builds the RTSP URL itself with percent-encoded credentials, because a
|
||||
-- password containing '@' in a hand-assembled URL is the exact bug the
|
||||
-- original project shipped.
|
||||
host text NOT NULL DEFAULT '',
|
||||
port integer NOT NULL DEFAULT 554,
|
||||
path text NOT NULL DEFAULT '/',
|
||||
username text NOT NULL DEFAULT '',
|
||||
-- Encrypted, never hashed: the agent has to be able to USE it. Same
|
||||
-- treatment as a site's broker password, and for the same reason.
|
||||
--
|
||||
-- This is a real widening of what the server holds. An RTSP credential is
|
||||
-- a live path into the camera itself, and until now it lived only on the
|
||||
-- shop PC under DPAPI. Putting it here is the price of onboarding a camera
|
||||
-- from head office, and it is sealed with the site id as additional data so
|
||||
-- a row copied between sites will not decrypt.
|
||||
password_enc bytea,
|
||||
|
||||
max_width integer NOT NULL DEFAULT 1280,
|
||||
tuning jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
|
||||
-- Bumped on every edit. The agent compares it to what it last applied, so
|
||||
-- a reconcile is cheap when nothing has changed - which is almost always.
|
||||
revision bigint NOT NULL DEFAULT 1,
|
||||
|
||||
-- Observed state, written by the agent. Kept beside desired state on
|
||||
-- purpose: "this camera is configured" and "this camera is working" are
|
||||
-- the two halves of the only question anyone asks about a camera, and
|
||||
-- splitting them across tables makes the screen that answers it a join
|
||||
-- nobody remembers to write.
|
||||
connected boolean,
|
||||
last_seen_at timestamptz,
|
||||
-- The most recent frame, as an object key. NOT a URL, for the same reason
|
||||
-- face images are keys: a stored URL is permanent and unrevocable.
|
||||
snapshot_key text NOT NULL DEFAULT '',
|
||||
snapshot_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- A tombstone, not a delete. The agent ADOPTS cameras it finds configured
|
||||
-- locally, so a hard delete would be undone on the next sync by the very
|
||||
-- camera the operator just removed.
|
||||
deleted_at timestamptz,
|
||||
|
||||
UNIQUE (site_id, camera_id)
|
||||
);
|
||||
|
||||
CREATE INDEX site_cameras_site_idx ON site_cameras (site_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX site_cameras_client_idx ON site_cameras (client_id) WHERE deleted_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
42
server/migrations/006_camera_checks.sql
Normal file
42
server/migrations/006_camera_checks.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
BEGIN;
|
||||
|
||||
-- Proving a camera works, from an office somewhere else.
|
||||
--
|
||||
-- The engine already knows how to answer both questions - `probe_source` says
|
||||
-- whether a stream can be opened and hands back a frame, `CommissionRun` says
|
||||
-- whether a person walking past produces a view worth enrolling - and both
|
||||
-- already phrase their answers for an installer. Neither was reachable from
|
||||
-- head office, so onboarding a camera meant typing an address and hoping.
|
||||
--
|
||||
-- A check is therefore a JOB the shop PC picks up on its next sync, not a call
|
||||
-- head office makes: a PC behind a router has no inbound route, and the
|
||||
-- placement check takes 25 seconds of somebody walking about, which is far
|
||||
-- longer than an HTTP request should live.
|
||||
ALTER TABLE site_cameras
|
||||
-- What has been asked for, and when. NULL means nothing is pending.
|
||||
ADD COLUMN IF NOT EXISTS check_kind text,
|
||||
ADD COLUMN IF NOT EXISTS check_requested_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS check_seconds integer NOT NULL DEFAULT 25,
|
||||
-- Claimed by the agent, so a request is not run twice by a PC that synced
|
||||
-- while the first attempt was still going.
|
||||
ADD COLUMN IF NOT EXISTS check_started_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS check_finished_at timestamptz,
|
||||
-- The engine's own answer, stored whole rather than unpacked into columns.
|
||||
--
|
||||
-- Deliberate: `verdict`, `headline` and `advice` are written for the person
|
||||
-- standing next to the camera, and re-wording them in the server and again
|
||||
-- in the browser is how three descriptions of one failure drift apart. The
|
||||
-- engine says it once; everything above passes it through.
|
||||
ADD COLUMN IF NOT EXISTS check_result jsonb,
|
||||
-- A frame captured during the check. Separate from snapshot_key: that one
|
||||
-- is the routine picture refreshed every minute, this one is the evidence
|
||||
-- for a specific check and must not be overwritten by the next refresh.
|
||||
ADD COLUMN IF NOT EXISTS check_image_key text NOT NULL DEFAULT '';
|
||||
|
||||
-- Partial index: the agent asks "is anything pending for my site" on every
|
||||
-- sync, and almost always the answer is no.
|
||||
CREATE INDEX IF NOT EXISTS site_cameras_pending_check_idx
|
||||
ON site_cameras (site_id)
|
||||
WHERE check_requested_at IS NOT NULL AND check_finished_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
46
server/migrations/007_unique_email.sql
Normal file
46
server/migrations/007_unique_email.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
BEGIN;
|
||||
|
||||
-- One address, one account.
|
||||
--
|
||||
-- 001 made the address unique PER CLIENT, so two companies could each have a
|
||||
-- user called alice@example.com and a platform admin could share an address
|
||||
-- with a tenant user. The intent was reasonable; it is not implementable. Sign
|
||||
-- in takes an email and a password and nothing else - no company field, no
|
||||
-- subdomain - so `UserByEmail` looks up `WHERE lower(email) = $1` and takes the
|
||||
-- first row Postgres happens to return.
|
||||
--
|
||||
-- Measured on a real database with one address held by a platform admin and a
|
||||
-- tenant owner: the first sign-in succeeded as the admin, `TouchUserLogin`
|
||||
-- rewrote that row, which moved it to the end of the heap, and every later
|
||||
-- sign-in with the SAME password returned "Email or password is incorrect."
|
||||
-- because the other account's hash was now first. The account was not locked,
|
||||
-- disabled, or wrong - it had simply stopped being the row the query found.
|
||||
-- Nothing in a log would explain that to anyone.
|
||||
--
|
||||
-- So: global uniqueness. Somebody who genuinely needs an account in two
|
||||
-- companies needs two addresses, which is the ordinary answer everywhere else
|
||||
-- and is honest about what the sign-in form can express.
|
||||
|
||||
-- Fail loudly and name the addresses rather than leaving a half-applied schema
|
||||
-- for the operator to work out from a constraint violation.
|
||||
DO $$
|
||||
DECLARE dupes text;
|
||||
BEGIN
|
||||
SELECT string_agg(e, ', ') INTO dupes FROM (
|
||||
SELECT lower(email) AS e FROM app_users GROUP BY 1 HAVING count(*) > 1
|
||||
) d;
|
||||
IF dupes IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'these addresses have an account in more than one company: %. '
|
||||
'Give each account its own address before applying this migration.',
|
||||
dupes;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP INDEX IF EXISTS app_users_email_idx;
|
||||
|
||||
-- Same NAME as before on purpose: the API turns a violation of this index into
|
||||
-- "That email address already has an account", by matching the name.
|
||||
CREATE UNIQUE INDEX app_users_email_idx ON app_users (lower(email));
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user