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
589 lines
15 KiB
Go
589 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// fakeStore is an in-memory Store. Handlers are where the security decisions
|
|
// live - which tenant, which message on failure, what is echoed back - and
|
|
// those are exactly what a real database would make slow and awkward to test.
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
|
|
users map[string]UserRecord // by lower-cased email
|
|
sessions map[string]*fakeSession
|
|
byAccess map[string]string // access hash hex -> session id
|
|
byRefresh map[string]string
|
|
|
|
visitors []Customer
|
|
history []VisitRow
|
|
footfall []FootfallPoint
|
|
totals Totals
|
|
sales SalesReport
|
|
sites []SiteHealth
|
|
enrolment map[string]Enrolment
|
|
|
|
// Recorded calls, so a test can assert what the handler asked for rather
|
|
// than only what it returned.
|
|
lastReport ReportQuery
|
|
lastProfile Profile
|
|
lastProfileClient string
|
|
lastPurchase PurchaseInput
|
|
audits []AuditEntry
|
|
|
|
// arrivals is the whole table; arrivalQ records what the handler asked for
|
|
// so a test can assert on the keyset window rather than only its output.
|
|
arrivals []Arrival
|
|
arrivalQ ArrivalQuery
|
|
arrivalsErr error
|
|
arrivalCalls int
|
|
|
|
pendingChecks []AgentCheckJob
|
|
checkResults []AgentCheckResult
|
|
releasedStale int
|
|
lastCodeActor string
|
|
lastCodeTTL time.Duration
|
|
lastCheckKind string
|
|
lastCheckSeconds int
|
|
|
|
cameras []Camera
|
|
agentCameras []AgentCamera
|
|
lastCameraReport AgentCameraReport
|
|
lastReportClient string
|
|
lastReportSite string
|
|
saveCameraErr error
|
|
lastSaved CameraInput
|
|
|
|
clients []ClientRow
|
|
lastNewClient NewClientInput
|
|
newClientErr error
|
|
loginTouched []string
|
|
|
|
profileErr error
|
|
purchaseErr error
|
|
forgetErr error
|
|
nextID int
|
|
|
|
agentTokens map[string]AgentPrincipal
|
|
imageKeys map[string]string
|
|
forgotten []string
|
|
}
|
|
|
|
type fakeSession struct {
|
|
id string
|
|
p auth.Principal
|
|
accessExp, refreshExp time.Time
|
|
revoked bool
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
users: map[string]UserRecord{},
|
|
sessions: map[string]*fakeSession{},
|
|
byAccess: map[string]string{},
|
|
byRefresh: map[string]string{},
|
|
enrolment: map[string]Enrolment{},
|
|
agentTokens: map[string]AgentPrincipal{},
|
|
imageKeys: map[string]string{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) addUser(email, password string, rec UserRecord) {
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
rec.Email = email
|
|
rec.PasswordHash = hash
|
|
rec.Found = true
|
|
if rec.ID == "" {
|
|
rec.ID = "user-" + email
|
|
}
|
|
if rec.Role == "" {
|
|
rec.Role = "manager"
|
|
}
|
|
f.users[auth.NormalizeEmail(email)] = rec
|
|
}
|
|
|
|
func (f *fakeStore) UserByEmail(_ context.Context, email string) (UserRecord, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.users[email]
|
|
if !ok {
|
|
return UserRecord{Found: false}, nil
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (f *fakeStore) TouchUserLogin(_ context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.loginTouched = append(f.loginTouched, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.nextID++
|
|
id := "sess-" + itoa(f.nextID)
|
|
var rec UserRecord
|
|
for _, u := range f.users {
|
|
if u.ID == n.UserID {
|
|
rec = u
|
|
}
|
|
}
|
|
s := &fakeSession{
|
|
id: id,
|
|
p: auth.Principal{
|
|
UserID: n.UserID, SessionID: id, ClientID: n.ClientID,
|
|
ClientName: rec.ClientName, Email: rec.Email,
|
|
FullName: rec.FullName, Role: rec.Role,
|
|
},
|
|
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
|
}
|
|
f.sessions[id] = s
|
|
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
|
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) lookup(index map[string]string, hash []byte, refresh bool) (
|
|
auth.Principal, time.Time, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id, ok := index[hex.EncodeToString(hash)]
|
|
if !ok {
|
|
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
|
}
|
|
s := f.sessions[id]
|
|
if s == nil || s.revoked {
|
|
return auth.Principal{}, time.Time{}, auth.ErrNoSession
|
|
}
|
|
if refresh {
|
|
return s.p, s.refreshExp, nil
|
|
}
|
|
return s.p, s.accessExp, nil
|
|
}
|
|
|
|
func (f *fakeStore) SessionByAccess(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
|
return f.lookup(f.byAccess, h, false)
|
|
}
|
|
|
|
func (f *fakeStore) SessionByRefresh(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
|
|
return f.lookup(f.byRefresh, h, true)
|
|
}
|
|
|
|
func (f *fakeStore) RotateSession(_ context.Context, id string, n NewSession) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
s := f.sessions[id]
|
|
if s == nil || s.revoked {
|
|
return auth.ErrNoSession
|
|
}
|
|
// Mirrors the real store: the old hashes stop resolving the moment the new
|
|
// ones are written.
|
|
for k, v := range f.byAccess {
|
|
if v == id {
|
|
delete(f.byAccess, k)
|
|
}
|
|
}
|
|
for k, v := range f.byRefresh {
|
|
if v == id {
|
|
delete(f.byRefresh, k)
|
|
}
|
|
}
|
|
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
|
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
|
|
s.accessExp, s.refreshExp = n.AccessExpiry, n.RefreshExp
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) RevokeSession(_ context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if s := f.sessions[id]; s != nil {
|
|
s.revoked = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) Footfall(_ context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastReport = q
|
|
return f.footfall, f.totals, nil
|
|
}
|
|
|
|
func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastReport = q
|
|
return f.sales, nil
|
|
}
|
|
|
|
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
|
return f.sites, nil
|
|
}
|
|
|
|
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
|
|
[]Customer, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastReport = ReportQuery{ClientID: clientID}
|
|
if limit < len(f.visitors) {
|
|
return f.visitors[:limit], nil
|
|
}
|
|
return f.visitors, nil
|
|
}
|
|
|
|
func (f *fakeStore) VisitorHistory(_ context.Context, _, _ string, _ int) ([]VisitRow, error) {
|
|
return f.history, nil
|
|
}
|
|
|
|
// Arrivals fakes the keyset window in memory: rows are held oldest-first, a
|
|
// cursor slices past it, and no cursor returns the newest Limit - the same
|
|
// contract the SQL implements, so a handler test that passes here is testing
|
|
// the handler and not a stub that is easier than the real thing.
|
|
func (f *fakeStore) Arrivals(_ context.Context, q ArrivalQuery) ([]Arrival, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.arrivalQ = q
|
|
f.arrivalCalls++
|
|
if f.arrivalsErr != nil {
|
|
return nil, f.arrivalsErr
|
|
}
|
|
rows := make([]Arrival, 0, len(f.arrivals))
|
|
for _, a := range f.arrivals {
|
|
if q.SiteID != "" && a.SiteID != q.SiteID {
|
|
continue
|
|
}
|
|
if q.AfterSeq != nil && a.Seq <= *q.AfterSeq {
|
|
continue
|
|
}
|
|
rows = append(rows, a)
|
|
}
|
|
if q.AfterSeq == nil && len(rows) > q.Limit {
|
|
// No cursor: the newest window, matching the real query.
|
|
rows = rows[len(rows)-q.Limit:]
|
|
} else if len(rows) > q.Limit {
|
|
rows = rows[:q.Limit]
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (f *fakeStore) SaveProfile(_ context.Context, clientID string, p Profile, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastProfile, f.lastProfileClient = p, clientID
|
|
return f.profileErr
|
|
}
|
|
|
|
func (f *fakeStore) RecordPurchase(_ context.Context, _ string, p PurchaseInput, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastPurchase = p
|
|
return f.purchaseErr
|
|
}
|
|
|
|
func (f *fakeStore) RedeemEnrolment(_ context.Context, hash []byte) (Enrolment, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
en, ok := f.enrolment[hex.EncodeToString(hash)]
|
|
if !ok {
|
|
return Enrolment{}, errors.New("unknown token")
|
|
}
|
|
// Single use, like the real UPDATE.
|
|
delete(f.enrolment, hex.EncodeToString(hash))
|
|
return en, nil
|
|
}
|
|
|
|
func (f *fakeStore) Cameras(_ context.Context, _, siteID string) ([]Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []Camera
|
|
for _, c := range f.cameras {
|
|
if siteID == "" || c.SiteID == siteID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) CameraByID(_ context.Context, _, id string) (Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, c := range f.cameras {
|
|
if c.ID == id {
|
|
return c, nil
|
|
}
|
|
}
|
|
return Camera{}, errors.New("no rows in result set")
|
|
}
|
|
|
|
func (f *fakeStore) SaveCamera(_ context.Context, _, siteID, cameraID string,
|
|
in CameraInput) (Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastSaved = in
|
|
if f.saveCameraErr != nil {
|
|
return Camera{}, f.saveCameraErr
|
|
}
|
|
// A real-shaped uuid: the handlers check the shape before touching SQL, so
|
|
// a placeholder id would exercise the 404 path instead of the one under
|
|
// test.
|
|
cam := Camera{ID: fakeCameraUUID(cameraID), SiteID: siteID, CameraID: cameraID,
|
|
Port: 554, Path: "/", MaxWidth: 1280, Enabled: true, Revision: 1}
|
|
if in.Label != nil {
|
|
cam.Label = *in.Label
|
|
}
|
|
if in.Host != nil {
|
|
cam.Host = *in.Host
|
|
}
|
|
if in.Username != nil {
|
|
cam.Username = *in.Username
|
|
}
|
|
cam.HasPassword = in.Password != nil && *in.Password != ""
|
|
f.cameras = append(f.cameras, cam)
|
|
return cam, nil
|
|
}
|
|
|
|
// fakeCameraUUID derives a stable uuid-shaped id from a camera name so tests
|
|
// can address a camera they just created without reading the response.
|
|
func fakeCameraUUID(cameraID string) string {
|
|
sum := sha256.Sum256([]byte(cameraID))
|
|
h := hex.EncodeToString(sum[:16])
|
|
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
|
}
|
|
|
|
func (f *fakeStore) DeleteCamera(_ context.Context, _, id string) (Camera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i, c := range f.cameras {
|
|
if c.ID == id {
|
|
f.cameras = append(f.cameras[:i], f.cameras[i+1:]...)
|
|
return c, nil
|
|
}
|
|
}
|
|
return Camera{}, errors.New("no rows in result set")
|
|
}
|
|
|
|
func (f *fakeStore) AgentCameras(_ context.Context, _ string) ([]AgentCamera, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.agentCameras, nil
|
|
}
|
|
|
|
func (f *fakeStore) ApplyAgentReport(_ context.Context, clientID, siteID string,
|
|
rep AgentCameraReport) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastCameraReport = rep
|
|
f.lastReportClient, f.lastReportSite = clientID, siteID
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) IssueEnrolmentCode(_ context.Context, clientID, siteID,
|
|
actorID, label string, ttl time.Duration) (EnrolmentCode, error) {
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, si := range f.sites {
|
|
if si.SiteID == siteID {
|
|
f.lastCodeActor, f.lastCodeTTL = actorID, ttl
|
|
return EnrolmentCode{
|
|
Code: "ABCDEF-123456-GHIJKL-789012", SiteID: siteID,
|
|
SiteName: si.Name, Label: label,
|
|
ExpiresAt: time.Now().Add(ttl).UTC(),
|
|
}, nil
|
|
}
|
|
}
|
|
return EnrolmentCode{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (f *fakeStore) RequestCheck(_ context.Context, _, id, kind string, seconds int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := range f.cameras {
|
|
if f.cameras[i].ID == id {
|
|
f.cameras[i].Check = CameraCheck{Kind: kind, Seconds: seconds, State: "requested"}
|
|
f.lastCheckKind, f.lastCheckSeconds = kind, seconds
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("no rows in result set")
|
|
}
|
|
|
|
func (f *fakeStore) ClaimChecks(_ context.Context, _ string) ([]AgentCheckJob, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := f.pendingChecks
|
|
f.pendingChecks = nil // claimed once, like the real UPDATE ... RETURNING
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) RecordCheckResult(_ context.Context, _ string, res AgentCheckResult) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.checkResults = append(f.checkResults, res)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.releasedStale++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.clients, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateClientWithOwner(_ context.Context, in NewClientInput) (
|
|
NewClientResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastNewClient = in
|
|
if f.newClientErr != nil {
|
|
return NewClientResult{}, f.newClientErr
|
|
}
|
|
pw := in.Password
|
|
if pw == "" {
|
|
pw = "generated-password"
|
|
}
|
|
return NewClientResult{ClientID: "new-client-id", Slug: in.Slug,
|
|
OwnerEmail: in.OwnerEmail, Password: pw}, nil
|
|
}
|
|
|
|
func (f *fakeStore) Audit(_ context.Context, e AuditEntry) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.audits = append(f.audits, e)
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b []byte
|
|
for n > 0 {
|
|
b = append([]byte{byte('0' + n%10)}, b...)
|
|
n /= 10
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// -- images and agents ------------------------------------------------------
|
|
|
|
func (f *fakeStore) SetAgentAPIToken(_ context.Context, agentID string, hash []byte) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.agentTokens == nil {
|
|
f.agentTokens = map[string]AgentPrincipal{}
|
|
}
|
|
f.agentTokens[hex.EncodeToString(hash)] = AgentPrincipal{
|
|
AgentID: agentID, ClientID: "client-acme", SiteID: "site-1",
|
|
Slug: "acme.store1", Client: "acme", Site: "store1",
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addAgent registers a plaintext agent token, hashed the way the middleware
|
|
// will look it up.
|
|
func (f *fakeStore) addAgent(token string, ap AgentPrincipal) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.agentTokens[hex.EncodeToString(auth.HashToken(token))] = ap
|
|
}
|
|
|
|
func (f *fakeStore) AgentByToken(_ context.Context, hash []byte) (AgentPrincipal, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
ap, ok := f.agentTokens[hex.EncodeToString(hash)]
|
|
if !ok {
|
|
return AgentPrincipal{}, errors.New("no such agent")
|
|
}
|
|
return ap, nil
|
|
}
|
|
|
|
func (f *fakeStore) VisitorImageKey(_ context.Context, _, visitorID string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.imageKeys[visitorID], nil
|
|
}
|
|
|
|
func (f *fakeStore) VisitorImageKeys(_ context.Context, _, visitorID string) ([]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if k := f.imageKeys[visitorID]; k != "" {
|
|
return []string{k}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) ForgetVisitor(_ context.Context, _, visitorID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.forgetErr != nil {
|
|
return f.forgetErr
|
|
}
|
|
f.forgotten = append(f.forgotten, visitorID)
|
|
delete(f.imageKeys, visitorID)
|
|
return nil
|
|
}
|
|
|
|
// fakeBlob records what the handlers asked storage to do. Deleting is the part
|
|
// worth recording: an erasure that reports success without removing the object
|
|
// is the failure this whole path exists to prevent.
|
|
type fakeBlob struct {
|
|
mu sync.Mutex
|
|
deleted []string
|
|
presigns []string
|
|
failNext error
|
|
}
|
|
|
|
func (b *fakeBlob) Key(client, site, objectID string, at time.Time) string {
|
|
return fmt.Sprintf("behavision/%s/%s/%04d/%02d/%02d/%s.jpg",
|
|
client, site, at.Year(), int(at.Month()), at.Day(), objectID)
|
|
}
|
|
|
|
func (b *fakeBlob) PresignPut(key string, _ time.Duration) (string, http.Header, error) {
|
|
h := http.Header{}
|
|
h.Set("x-amz-acl", "private")
|
|
h.Set("Content-Type", "image/jpeg")
|
|
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", h, nil
|
|
}
|
|
|
|
func (b *fakeBlob) PresignGet(key string, _ time.Duration) (string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.presigns = append(b.presigns, key)
|
|
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", nil
|
|
}
|
|
|
|
func (b *fakeBlob) Delete(_ context.Context, key string) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.failNext != nil {
|
|
err := b.failNext
|
|
b.failNext = nil
|
|
return err
|
|
}
|
|
b.deleted = append(b.deleted, key)
|
|
return nil
|
|
}
|