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
153 lines
5.2 KiB
Go
153 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
)
|
|
|
|
// SetAgentAPIToken stores the hash of a store PC's HTTPS credential.
|
|
//
|
|
// Hashed, not encrypted, unlike the broker password: this one is never handed
|
|
// back out. It is shown once at enrolment and the agent keeps it, so a database
|
|
// dump contains nothing usable.
|
|
func (s *Store) SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`UPDATE agents SET api_token_hash = $2 WHERE id = $1::uuid`, agentID, hash)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) AgentByToken(ctx context.Context, hash []byte) (api.AgentPrincipal, error) {
|
|
var ap api.AgentPrincipal
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT a.id::text, a.client_id::text, a.site_id::text, a.mqtt_username,
|
|
c.slug, si.slug
|
|
FROM agents a
|
|
JOIN sites si ON si.id = a.site_id AND si.active
|
|
JOIN clients c ON c.id = a.client_id AND c.active
|
|
WHERE a.api_token_hash = $1`, hash).
|
|
Scan(&ap.AgentID, &ap.ClientID, &ap.SiteID, &ap.Slug, &ap.Client, &ap.Site)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return api.AgentPrincipal{}, errors.New("no such agent")
|
|
}
|
|
return ap, err
|
|
}
|
|
|
|
// VisitorImageKey is the most recent surviving photo of one person.
|
|
//
|
|
// image_deleted_at is checked, not just image_key: a key that has been erased
|
|
// is still in the row as the record that it WAS erased, and handing it to the
|
|
// presigner would produce a link to an object that is gone - or, worse, to one
|
|
// that was re-created under the same name.
|
|
func (s *Store) VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error) {
|
|
var key string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT image_key FROM visits
|
|
WHERE client_id = $1 AND visitor_id = $2::uuid
|
|
AND image_key <> '' AND image_deleted_at IS NULL
|
|
ORDER BY occurred_at DESC
|
|
LIMIT 1`, clientID, visitorID).Scan(&key)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return key, err
|
|
}
|
|
|
|
// VisitorImageKeys is every object belonging to one person - the first step of
|
|
// an erasure request.
|
|
func (s *Store) VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT image_key FROM visits
|
|
WHERE client_id = $1 AND visitor_id = $2::uuid
|
|
AND image_key <> '' AND image_deleted_at IS NULL`, clientID, visitorID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var k string
|
|
if err := rows.Scan(&k); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ForgetVisitor is the database half of erasure.
|
|
//
|
|
// What goes and what stays is a deliberate line:
|
|
//
|
|
// - the biometric template is DELETED outright, not flagged. Template
|
|
// inversion reconstructs a recognisable face from an ArcFace embedding, so
|
|
// a soft-deleted vector is a retained photograph by another name.
|
|
// - the profile goes: a name, a phone number and a date of birth are exactly
|
|
// what the request is about.
|
|
// - visits STAY, with the person unlinked. They are the shop's own footfall
|
|
// history, and silently changing last quarter's numbers because one
|
|
// customer exercised a right is both wrong and detectable.
|
|
// - the visitors row stays with deleted_at set, so the same face cannot be
|
|
// re-enrolled as a brand new person the next time they walk in.
|
|
func (s *Store) ForgetVisitor(ctx context.Context, clientID, visitorID string) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
var exists bool
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT true FROM visitors WHERE id = $1::uuid AND client_id = $2`,
|
|
visitorID, clientID).Scan(&exists)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return errors.New("no such visitor")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM visitor_embeddings
|
|
WHERE visitor_id = $1::uuid AND client_id = $2`,
|
|
visitorID, clientID); err != nil {
|
|
return fmt.Errorf("delete templates: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM visitor_profiles
|
|
WHERE visitor_id = $1::uuid AND client_id = $2`,
|
|
visitorID, clientID); err != nil {
|
|
return fmt.Errorf("delete profile: %w", err)
|
|
}
|
|
// The consent record itself survives as a revocation. Deleting it would
|
|
// destroy the proof of what we were permitted to do and when, which is the
|
|
// thing an auditor actually asks for.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE consents SET revoked_at = COALESCE(revoked_at, now())
|
|
WHERE visitor_id = $1::uuid AND client_id = $2`,
|
|
visitorID, clientID); err != nil {
|
|
return fmt.Errorf("revoke consents: %w", err)
|
|
}
|
|
// The objects are already gone from storage by the time this runs; this
|
|
// records that, and stops anything presigning a dead key.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE visits SET image_deleted_at = now()
|
|
WHERE client_id = $1 AND visitor_id = $2::uuid
|
|
AND image_key <> '' AND image_deleted_at IS NULL`,
|
|
clientID, visitorID); err != nil {
|
|
return fmt.Errorf("mark images deleted: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE visitors
|
|
SET deleted_at = now(), label = 'Erased'
|
|
WHERE id = $1::uuid AND client_id = $2`,
|
|
visitorID, clientID); err != nil {
|
|
return fmt.Errorf("mark visitor erased: %w", err)
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|