Files
Behavision/server/internal/api/api.go
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

437 lines
18 KiB
Go

// Package api is the request/response half of the server: staff signing in
// from the desktop app, reports, the in-store customer form, and a fresh PC
// collecting its broker credentials.
//
// The MQTT consumer in `ingest` is the other half and shares nothing with this
// but the database. That separation is deliberate — an agent is authenticated
// by the broker and identified by its topic, a person is authenticated by a
// password and identified by a session. Merging the two would mean one code
// path deciding two very different questions about who is asking.
//
// Every handler here derives the tenant from the SESSION, never from the
// request body. A client_id parameter that the caller can set is a cross-tenant
// read waiting for someone to try it.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
// Store is what the API needs from the database. Declared here, implemented in
// package store, so handlers can be tested against a fake with no Postgres —
// the same pattern ingest already uses.
type Store interface {
// --- identity ---
UserByEmail(ctx context.Context, email string) (UserRecord, error)
TouchUserLogin(ctx context.Context, userID string) error
CreateSession(ctx context.Context, s NewSession) error
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
RotateSession(ctx context.Context, sessionID string, s NewSession) error
RevokeSession(ctx context.Context, sessionID string) error
// --- reports ---
Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error)
Conversion(ctx context.Context, q ReportQuery) (SalesReport, error)
SiteHealth(ctx context.Context, clientID string) ([]SiteHealth, error)
// --- people ---
SearchVisitors(ctx context.Context, clientID, query string, limit int) ([]Customer, error)
VisitorHistory(ctx context.Context, clientID, visitorID string, limit int) ([]VisitRow, error)
// Arrivals is the live feed: who walked in, newest window or from a cursor.
Arrivals(ctx context.Context, q ArrivalQuery) ([]Arrival, error)
SaveProfile(ctx context.Context, clientID string, p Profile, actor string) error
RecordPurchase(ctx context.Context, clientID string, p PurchaseInput, actor string) error
// --- cameras ---
Cameras(ctx context.Context, clientID, siteID string) ([]Camera, error)
CameraByID(ctx context.Context, clientID, id string) (Camera, error)
SaveCamera(ctx context.Context, clientID, siteID, cameraID string, in CameraInput) (Camera, error)
DeleteCamera(ctx context.Context, clientID, id string) (Camera, error)
// AgentCameras is the only path that decrypts a camera password, and it is
// reachable only with that site's own agent token.
AgentCameras(ctx context.Context, siteID string) ([]AgentCamera, error)
ApplyAgentReport(ctx context.Context, clientID, siteID string, rep AgentCameraReport) error
// --- claiming a shop PC ---
IssueEnrolmentCode(ctx context.Context, clientID, siteID, actorID,
label string, ttl time.Duration) (EnrolmentCode, error)
// --- proving a camera works ---
RequestCheck(ctx context.Context, clientID, id, kind string, seconds int) error
ClaimChecks(ctx context.Context, siteID string) ([]AgentCheckJob, error)
RecordCheckResult(ctx context.Context, siteID string, res AgentCheckResult) error
ReleaseStaleChecks(ctx context.Context, olderThan time.Duration) error
// --- platform administration ---
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
ListClients(ctx context.Context) ([]ClientRow, error)
// --- enrolment ---
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error
AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error)
// --- images ---
VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error)
VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error)
ForgetVisitor(ctx context.Context, clientID, visitorID string) error
Audit(ctx context.Context, e AuditEntry)
}
type Server struct {
Store Store
Log *log.Logger
Bootstrap BootstrapConfig
// Now is injectable so expiry logic is testable without sleeping.
Now func() time.Time
// Throttle limits failed sign-ins per account, IPThrottle per source
// address. Built on first use so a zero-value Server is still safe: an
// unlimited login endpoint reached by forgetting one field is not a failure
// mode worth leaving open.
Throttle *Throttle
IPThrottle *Throttle
// Blob is object storage. Nil means this deployment stores no images,
// which is a supported configuration and the default: the product shipped
// without images on purpose, and turning them on changes what the database
// is under data-protection law.
Blob BlobStore
// Assistant answers questions in plain language by calling the same
// business questions the screens ask. Nil means this deployment has no
// API key, which is supported: the UI hides the panel.
Assistant Assistant
// Hub wakes live arrival streams when the MQTT consumer records a visit.
// Nil is supported and means the streams fall back to their slow tick -
// a server assembled without one is slower, not broken.
Hub *Hub
once sync.Once
}
func (s *Server) throttles() (perUser, perIP *Throttle) {
s.once.Do(func() {
if s.Throttle == nil {
s.Throttle = NewThrottle(10, 15*time.Minute)
}
if s.IPThrottle == nil {
// Far looser than the per-account limit, and deliberately so. A
// whole shop sits behind one NAT address, so a per-IP limit tight
// enough to stop a targeted attack locks out every member of staff
// because one of them fumbled their password. The per-ACCOUNT limit
// is what actually stops somebody working through a password list;
// this is only a backstop against spraying one guess across many
// addresses.
s.IPThrottle = NewThrottle(60, 15*time.Minute)
}
})
return s.Throttle, s.IPThrottle
}
// BootstrapConfig is what a newly enrolled PC is told about the estate. It
// comes from the server's own environment, never from the request: an agent
// asking where to connect must not be able to influence the answer.
type BootstrapConfig struct {
MQTTURL string
CACert string
Models []ModelRef
}
type ModelRef struct {
Name string `json:"name"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
}
func (s *Server) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now().UTC()
}
func (s *Server) logf(format string, v ...any) {
if s.Log != nil {
s.Log.Printf(format, v...)
}
}
// Routes returns the mux. Patterns use method-qualified paths so a GET to a
// write endpoint is a 405 rather than falling through to the catch-all as a
// confusing 404.
func (s *Server) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh)
mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout))
mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe))
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
// Cameras, onboarded from head office. The shop PC still does the
// connecting - it is the only thing on the camera's network - so these
// write desired state that its agent pulls and applies.
mux.HandleFunc("GET /api/cameras", s.authed(s.handleCameras))
mux.HandleFunc("POST /api/sites/{site}/cameras", s.authed(s.handleCreateCamera))
mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera))
mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera))
// Prove a camera works: "connection" asks whether the shop PC can open the
// stream, "placement" asks whether somebody walking past produces a view
// good enough to recognise. Two questions, because a camera passes the
// first and fails the second all the time - that is the Office1 case.
mux.HandleFunc("POST /api/cameras/{id}/check", s.authed(s.handleRequestCheck))
// The end-to-end answer for one shop, assembled from what head office
// already knows - so it works even when the shop PC is off, which is one of
// the things it reports.
mux.HandleFunc("GET /api/sites/{site}/check", s.authed(s.handleSiteCheck))
mux.HandleFunc("POST /api/sites/{site}/enrolment-code",
s.authed(s.handleIssueEnrolmentCode))
// The assistant. Every tool it calls runs as the signed-in user, so it can
// only ever see what the person asking could already see.
mux.HandleFunc("POST /api/assistant", s.authed(s.handleAssistant))
// The live feed. `visitors` searches a customer list by name; `visits`
// answers the question a shop screen or a mobile app actually asks - who
// came through the door just now - and carries each person's photo with
// them so rendering four simultaneous arrivals is one request, not nine.
mux.HandleFunc("GET /api/visits", s.authed(s.handleArrivals))
mux.HandleFunc("GET /api/visits/stream", s.authed(s.handleArrivalStream))
mux.HandleFunc("GET /api/visitors", s.authed(s.handleVisitors))
mux.HandleFunc("GET /api/visitors/{id}/history", s.authed(s.handleVisitorHistory))
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
// Platform administration. Not public registration: an open endpoint that
// mints tenants is a far larger thing to secure than one behind an account
// that already exists. The `provision` CLI remains the bootstrap path,
// because creating the first admin cannot require being signed in as one.
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
// Not session-authenticated: this is how a PC with no credentials gets
// some. The enrolment token is the credential.
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
// Authenticated by the agent's own API token, not a user session.
mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL))
// What this shop PC should be running, and what it reports back.
mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras))
mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport))
mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks))
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage))
// The erasure path. Destroys the template and the photo; keeps the
// anonymous visit counts, which are legitimate aggregate data.
mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor))
return mux
}
// ---------------------------------------------------------------- plumbing
type ctxKey int
const principalKey ctxKey = 1
// PrincipalFrom returns the authenticated caller. Handlers behind authed() can
// rely on it being present.
func PrincipalFrom(ctx context.Context) auth.Principal {
p, _ := ctx.Value(principalKey).(auth.Principal)
return p
}
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := auth.BearerToken(r)
if tok == "" {
unauthorized(w, "sign in to continue")
return
}
p, expires, err := s.Store.SessionByAccess(r.Context(), auth.HashToken(tok))
if err != nil {
// One message for "no such session" and "revoked": telling the
// difference is only useful to someone probing tokens.
unauthorized(w, "sign in to continue")
return
}
if s.now().After(expires) {
// A distinct code so the client refreshes silently instead of
// throwing the user back to a login form every twelve hours.
writeErr(w, http.StatusUnauthorized, "token_expired",
"your session needs refreshing")
return
}
next(w, r.WithContext(context.WithValue(r.Context(), principalKey, p)))
}
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(body); err != nil {
// Nothing useful left to do: the status line is already sent.
return
}
}
// writeCompactJSON writes JSON with no trailing newline.
//
// Encoder.Encode appends one, which inside an SSE `data:` line closes the event
// a frame early. Everywhere else that newline is invisible; here it is a
// protocol bug, so the stream gets its own writer rather than a comment on the
// shared one asking people to remember.
func writeCompactJSON(w io.Writer, body any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
// writeErr uses the shape the desktop client parses: it shows `message`
// verbatim, so that string is user-facing text, not a developer note.
func writeErr(w http.ResponseWriter, code int, kind, message string) {
writeJSON(w, code, map[string]string{"error": kind, "message": message})
}
func unauthorized(w http.ResponseWriter, msg string) {
writeErr(w, http.StatusUnauthorized, "unauthorized", msg)
}
func badRequest(w http.ResponseWriter, msg string) {
writeErr(w, http.StatusBadRequest, "bad_request", msg)
}
func (s *Server) serverError(w http.ResponseWriter, where string, err error) {
// The error text stays in the log. A database error surfaced to a shop
// floor tells an attacker about the schema and tells the operator nothing
// they can act on.
s.logf("ERROR %s: %v", where, err)
writeErr(w, http.StatusInternalServerError, "server_error",
"something went wrong at our end - please try again")
}
// decode reads a JSON body with a hard size limit. Unknown fields are rejected
// so a client sending `client_id` to a handler that ignores it finds out,
// rather than believing it took effect.
func decode(w http.ResponseWriter, r *http.Request, out any) error {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
return errors.New("could not read the request: " + err.Error())
}
return nil
}
// decodeOptional is decode for a body where every field has a default.
//
// An empty body is then a legitimate request - "mint me a code, I have nothing
// to say about it" - and answering that with 400 "could not read the request:
// EOF" is a confusing failure for the simplest possible call. Not the default,
// because for most endpoints an empty body IS the mistake, and silently
// treating it as an empty object would let a PUT wipe a profile.
func decodeOptional(w http.ResponseWriter, r *http.Request, out any) error {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return errors.New("could not read the request: " + err.Error())
}
return nil
}
func queryInt(r *http.Request, name string, def, max int) int {
v, err := strconv.Atoi(r.URL.Query().Get(name))
if err != nil || v <= 0 {
return def
}
if v > max {
return max
}
return v
}
func trim(s string) string { return strings.TrimSpace(s) }
// looksLikeUUID checks the shape of a path id before it reaches SQL.
//
// Every id in this schema is a uuid, and `$1::uuid` on a malformed string is a
// Postgres cast error - which surfaces as a 500. A mistyped URL is not a server
// fault, and answering one with "something went wrong at our end" sends an
// operator looking for an outage that is not there. It also means a scanner
// walking the API can tell, from the status code alone, which of its guesses
// reached a query.
func looksLikeUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, c := range s {
switch i {
case 8, 13, 18, 23:
if c != '-' {
return false
}
default:
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
if !isHex {
return false
}
}
}
return true
}
// ErrNoSecrets means the server has no encryption key, so camera passwords can
// be neither stored nor handed out. Declared here so handlers can recognise it
// without importing the store package.
var ErrNoSecrets = errors.New("this server has no encryption key, so camera passwords cannot be stored")
// BlobStore is what the API needs from object storage. Declared here and
// implemented by internal/blob, so the handlers can be tested without a bucket
// and so a deployment with images switched off is a nil field rather than a
// second code path.
type BlobStore interface {
Key(client, site, objectID string, at time.Time) string
PresignPut(key string, ttl time.Duration) (string, http.Header, error)
PresignGet(key string, ttl time.Duration) (string, error)
Delete(ctx context.Context, key string) error
}
// newObjectID names one uploaded image.
//
// Random rather than derived from the event id: an object key is guessable if
// it is derived, and this bucket allows anonymous listing, so a predictable key
// would let someone enumerate a shop's customers by date. It is also the only
// identifier the agent gets, and it must not encode who the person is.
func newObjectID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Cannot happen short of a broken kernel, and a predictable key here
// would be worse than a failed upload.
panic("api: no entropy for an object id: " + err.Error())
}
return hex.EncodeToString(b[:])
}