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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
)
func seedPlatformAdmin(fs *fakeStore) {
// No client id. A platform admin is defined by the ABSENCE of a tenant,
// not by a flag, which is what keeps its scope from depending on which
// query happens to check first.
fs.addUser("root@loyaly.ai", "admin123", UserRecord{
ID: "admin-1", ClientID: "", FullName: "Platform", Role: "admin", Active: true,
})
}
func TestAnAdminCreatesATenantAndItsOwnerInOneCall(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
"company_name": "Nearle Retail",
"owner_email": "aravind@nearle.in",
"owner_name": "Aravind",
"password": "admin123",
})
if rec.Code != http.StatusCreated {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out NewClientResult
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Password != "admin123" {
t.Errorf("the password is shown once and must be the one that was set, got %q", out.Password)
}
// The slug becomes an MQTT topic segment, so it has to be derived rather
// than left to whatever the operator typed.
if out.Slug != "nearle-retail" {
t.Errorf("slug %q, want it derived from the company name", out.Slug)
}
}
// A tenant user must not learn that a platform-administration surface exists.
func TestATenantUserGets404FromTheAdminRoutes(t *testing.T) {
s, fs := newServer(t)
seedUser(fs) // a manager inside client-acme
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, call := range []struct{ method, path string }{
{"GET", "/api/admin/clients"},
{"POST", "/api/admin/clients"},
} {
rec := do(t, s, call.method, call.path, sess.Token,
map[string]string{"company_name": "X", "owner_email": "x@x.com"})
if rec.Code != http.StatusNotFound {
t.Errorf("%s %s: got %d, want 404 - a 403 confirms the surface exists",
call.method, call.path, rec.Code)
}
}
}
// The one that would be a cross-tenant breach: an account INSIDE a client whose
// role happens to be "admin". Scope is the absent client id, not the role.
func TestARoleOfAdminInsideATenantIsNotAPlatformAdmin(t *testing.T) {
s, fs := newServer(t)
fs.addUser("sneaky@acme.com", "admin123", UserRecord{
ID: "u9", ClientID: "client-acme", Role: "admin", Active: true,
})
sess := login(t, s, "sneaky@acme.com", "admin123")
if rec := do(t, s, "GET", "/api/admin/clients", sess.Token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("a tenant-scoped 'admin' reached the platform routes: %d", rec.Code)
}
}
func TestTheAdminRoutesNeedASession(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
if rec := do(t, s, "GET", "/api/admin/clients", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
}
// Without an owner the tenant is invisible-broken: it looks normal in every
// list and nobody can sign into it.
func TestACompanyWithNoOwnerEmailIsRefused(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", "/api/admin/clients", sess.Token,
map[string]string{"company_name": "Nearle"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "nobody can sign in") {
t.Errorf("the message should say why it matters: %s", rec.Body.String())
}
}
// A clashing slug means the operator is about to hand somebody else's tenant to
// a new owner. It must fail, and say which mistake it was.
func TestADuplicateIsAConflictAnOperatorCanActapon(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
for _, tc := range []struct{ pgErr, want string }{
{`duplicate key value violates unique constraint "clients_slug_key"`, "short name already exists"},
{`duplicate key value violates unique constraint "app_users_email_idx"`, "already has an account"},
} {
fs.newClientErr = errors.New(tc.pgErr)
rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
"company_name": "Nearle", "owner_email": "a@nearle.in"})
if rec.Code != http.StatusConflict {
t.Errorf("got %d, want 409 for %q", rec.Code, tc.pgErr)
}
if !strings.Contains(rec.Body.String(), tc.want) {
t.Errorf("message %q does not name the clash", rec.Body.String())
}
}
}
func TestSlugsCannotChangeWhatAnMQTTTopicMeans(t *testing.T) {
for in, want := range map[string]string{
"Nearle Retail Pvt Ltd": "nearle-retail-pvt-ltd",
" Acme ": "acme",
"a/b+c#d": "a-b-c-d", // the three MQTT wildcards and separator
"---Nearle---": "nearle",
"Café 21": "caf-21",
"!!!": "",
} {
if got := slugify(in); got != want {
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
}
}
}
// Creating a tenant is rare and consequential. It should leave a name against it.
func TestCreatingATenantIsAudited(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
"company_name": "Nearle", "owner_email": "aravind@nearle.in"})
fs.mu.Lock()
defer fs.mu.Unlock()
for _, a := range fs.audits {
if a.Action == "client.create" && a.ActorID == "admin-1" {
return
}
}
t.Fatalf("no audit row for creating a tenant: %+v", fs.audits)
}
// An operator inventing a password for somebody else invents a weak one and
// sends it over chat. Omitting it must generate one, not create an account with
// an empty password.
func TestAnOmittedPasswordIsGeneratedNotBlank(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
sess := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", "/api/admin/clients", sess.Token, map[string]string{
"company_name": "Nearle", "owner_email": "aravind@nearle.in"})
var out NewClientResult
json.Unmarshal(rec.Body.Bytes(), &out) //nolint:errcheck
if out.Password == "" {
t.Fatal("no password was returned, so nobody can ever sign in")
}
}

436
server/internal/api/api.go Normal file
View File

@@ -0,0 +1,436 @@
// 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[:])
}

View File

@@ -0,0 +1,650 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
// Real-shaped ids: every id in the schema is a uuid, and the handlers now
// check that before touching SQL, so a placeholder like "v1" would be testing
// the wrong path.
const (
visitorAID = "98bf7587-4d55-4ae0-99e0-de8c05dd3e78"
visitorB = "11111111-2222-3333-4444-555555555555"
visitorA = "/api/visitors/" + visitorAID
)
func newServer(t *testing.T) (*Server, *fakeStore) {
t.Helper()
fs := newFakeStore()
return &Server{Store: fs}, fs
}
func do(t *testing.T, s *Server, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
rdr = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, rdr)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
// Every request looks like it came through the proxy, which is where the
// throttle reads the client address from.
req.Header.Set("X-Forwarded-For", "203.0.113.9")
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
return rec
}
func login(t *testing.T, s *Server, email, password string) Session {
t.Helper()
rec := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": email, "password": password})
if rec.Code != http.StatusOK {
t.Fatalf("login: got %d, body %s", rec.Code, rec.Body.String())
}
var sess Session
if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil {
t.Fatal(err)
}
return sess
}
func seedUser(fs *fakeStore) {
fs.addUser("manager@acme.com", "correct horse battery", UserRecord{
ID: "u1", ClientID: "client-acme", ClientName: "Acme Retail",
FullName: "Asha", Role: "manager", Active: true,
})
}
// ---------------------------------------------------------------- sign in
func TestLoginReturnsSessionAndNeverThePasswordHash(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
rec := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "Manager@Acme.com", "password": "correct horse battery"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The single most important assertion here: whatever else changes about the
// response shape, a hash must never travel to a shop floor.
if strings.Contains(body, "$2a$") || strings.Contains(body, "password_hash") {
t.Fatalf("password hash leaked into the response: %s", body)
}
var sess Session
if err := json.Unmarshal([]byte(body), &sess); err != nil {
t.Fatal(err)
}
if sess.Token == "" || sess.RefreshToken == "" {
t.Fatal("expected both tokens")
}
if sess.Token == sess.RefreshToken {
t.Fatal("access and refresh tokens must be different secrets")
}
if sess.User.ClientID != "client-acme" || sess.User.Client != "Acme Retail" {
t.Fatalf("user not populated: %+v", sess.User)
}
// The address is normalised on the way in, so a capitalised sign-in and a
// lower-case one are one account.
if len(fs.loginTouched) != 1 {
t.Fatalf("expected last_login to be recorded once, got %v", fs.loginTouched)
}
}
func TestUnknownEmailAndWrongPasswordAreIndistinguishable(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
unknown := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "nobody@acme.com", "password": "correct horse battery"})
wrong := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "not the password"})
if unknown.Code != http.StatusUnauthorized || wrong.Code != http.StatusUnauthorized {
t.Fatalf("codes: unknown=%d wrong=%d", unknown.Code, wrong.Code)
}
// Any difference here is a membership oracle for a customer's staff list.
if unknown.Body.String() != wrong.Body.String() {
t.Fatalf("responses differ:\n unknown: %s\n wrong: %s",
unknown.Body.String(), wrong.Body.String())
}
}
func TestInactiveUserCannotSignIn(t *testing.T) {
s, fs := newServer(t)
fs.addUser("gone@acme.com", "correct horse battery", UserRecord{
ID: "u2", ClientID: "client-acme", Active: false,
})
rec := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "gone@acme.com", "password": "correct horse battery"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("a deactivated account signed in: %d %s", rec.Code, rec.Body.String())
}
}
func TestRepeatedFailuresAreThrottledAndSuccessClearsIt(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
s.Throttle = NewThrottle(3, time.Minute)
s.IPThrottle = NewThrottle(1000, time.Minute)
for i := 0; i < 3; i++ {
do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "wrong"})
}
blocked := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
if blocked.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 after 3 failures, got %d", blocked.Code)
}
// A cleared window lets the real password through again, and the success
// resets the counter so the next mistake does not lock the shop out.
s.Throttle = NewThrottle(3, time.Minute)
s.IPThrottle = NewThrottle(1000, time.Minute)
login(t, s, "manager@acme.com", "correct horse battery")
for i := 0; i < 2; i++ {
do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "wrong"})
}
again := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
if again.Code != http.StatusOK {
t.Fatalf("success did not reset the counter: %d", again.Code)
}
}
// ---------------------------------------------------------------- sessions
func TestAuthenticatedRoutesRequireAToken(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
for _, path := range []string{
"/api/auth/me", "/api/reports/footfall", "/api/reports/conversion",
"/api/visitors", "/api/sites",
} {
if rec := do(t, s, "GET", path, "", nil); rec.Code != http.StatusUnauthorized {
t.Errorf("%s without a token returned %d, want 401", path, rec.Code)
}
if rec := do(t, s, "GET", path, "not-a-real-token", nil); rec.Code != http.StatusUnauthorized {
t.Errorf("%s with a bogus token returned %d, want 401", path, rec.Code)
}
}
}
func TestExpiredAccessTokenAsksForARefreshRatherThanALogin(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
now := time.Now()
s.Now = func() time.Time { return now }
sess := login(t, s, "manager@acme.com", "correct horse battery")
// Move past the access lifetime but stay well inside the refresh one.
s.Now = func() time.Time { return now.Add(auth.AccessTTL + time.Minute) }
rec := do(t, s, "GET", "/api/auth/me", sess.Token, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expired token returned %d", rec.Code)
}
var body map[string]string
json.Unmarshal(rec.Body.Bytes(), &body) //nolint:errcheck
// The distinct code is what lets the desktop app refresh silently instead
// of throwing a shop assistant back to a login form twice a day.
if body["error"] != "token_expired" {
t.Fatalf("want token_expired so the client can refresh, got %q", body["error"])
}
}
func TestRefreshRotatesAndTheOldRefreshTokenStopsWorking(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
first := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/auth/refresh", "",
map[string]string{"refresh_token": first.RefreshToken})
if rec.Code != http.StatusOK {
t.Fatalf("refresh failed: %d %s", rec.Code, rec.Body.String())
}
var second Session
json.Unmarshal(rec.Body.Bytes(), &second) //nolint:errcheck
if second.Token == first.Token || second.RefreshToken == first.RefreshToken {
t.Fatal("refresh must issue new secrets, not return the same ones")
}
if got := do(t, s, "GET", "/api/auth/me", second.Token, nil); got.Code != http.StatusOK {
t.Fatalf("new access token rejected: %d", got.Code)
}
// A refresh token copied off a resold shop PC must not keep working
// alongside the real one.
replay := do(t, s, "POST", "/api/auth/refresh", "",
map[string]string{"refresh_token": first.RefreshToken})
if replay.Code != http.StatusUnauthorized {
t.Fatalf("the old refresh token still works: %d", replay.Code)
}
if old := do(t, s, "GET", "/api/auth/me", first.Token, nil); old.Code == http.StatusOK {
t.Fatal("the old access token still works after rotation")
}
}
func TestLogoutRevokesTheSession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
if rec := do(t, s, "POST", "/api/auth/logout", sess.Token, nil); rec.Code != http.StatusNoContent {
t.Fatalf("logout returned %d", rec.Code)
}
if rec := do(t, s, "GET", "/api/auth/me", sess.Token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("token still valid after logout: %d", rec.Code)
}
if rec := do(t, s, "POST", "/api/auth/refresh", "",
map[string]string{"refresh_token": sess.RefreshToken}); rec.Code != http.StatusUnauthorized {
t.Fatalf("refresh still works after logout: %d", rec.Code)
}
}
// ---------------------------------------------------------------- tenancy
func TestTenantComesFromTheSessionNotTheRequest(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
// A client_id in the query string must not steer the report.
do(t, s, "GET", "/api/reports/footfall?client_id=client-rival&site=", sess.Token, nil)
if fs.lastReport.ClientID != "client-acme" {
t.Fatalf("report ran against %q - a caller-supplied tenant was honoured",
fs.lastReport.ClientID)
}
do(t, s, "GET", "/api/visitors?q=x", sess.Token, nil)
if fs.lastReport.ClientID != "client-acme" {
t.Fatalf("visitor search ran against %q", fs.lastReport.ClientID)
}
}
func TestProfileWriteUsesThePathIdNotTheBody(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "PUT", visitorA + "/profile", sess.Token, Profile{
// A body id pointing at somebody else's record.
VisitorID: visitorB,
FullName: "Asha Menon",
Consent: true,
})
if rec.Code != http.StatusNoContent {
t.Fatalf("save profile returned %d: %s", rec.Code, rec.Body.String())
}
if fs.lastProfile.VisitorID != visitorAID {
t.Fatalf("wrote to %q - the body overrode the URL",
fs.lastProfile.VisitorID)
}
if fs.lastProfileClient != "client-acme" {
t.Fatalf("wrote into tenant %q", fs.lastProfileClient)
}
// Naming a face is the moment ordinary PII gets attached to a biometric
// template, so it has to leave a trace.
var found bool
for _, a := range fs.audits {
if a.Action == "profile.save" && a.EntityID == visitorAID {
found = true
}
}
if !found {
t.Fatalf("profile save was not audited: %+v", fs.audits)
}
}
func TestStaffCanWriteProfilesButAViewerCannot(t *testing.T) {
s, fs := newServer(t)
fs.addUser("viewer@acme.com", "correct horse battery", UserRecord{
ID: "u3", ClientID: "client-acme", Role: "viewer", Active: true,
})
sess := login(t, s, "viewer@acme.com", "correct horse battery")
rec := do(t, s, "PUT", visitorA + "/profile", sess.Token,
Profile{FullName: "Someone"})
if rec.Code != http.StatusForbidden {
t.Fatalf("an unknown role could write a profile: %d", rec.Code)
}
}
// ---------------------------------------------------------------- reports
func TestReportWindowValidation(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
bad := []string{
"/api/reports/footfall?bucket=fortnight",
"/api/reports/footfall?tz=Mars/Olympus",
"/api/reports/footfall?from=2026-03-01&to=2026-02-01",
"/api/reports/footfall?from=not-a-date",
"/api/reports/footfall?from=2000-01-01&to=2026-01-01",
}
for _, path := range bad {
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusBadRequest {
t.Errorf("%s returned %d, want 400", path, rec.Code)
}
}
}
func TestAnInclusiveEndDateIncludesItsOwnLastDay(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
do(t, s, "GET", "/api/reports/footfall?from=2026-08-01&to=2026-08-07", sess.Token, nil)
// "1st to the 7th" means the 7th is in the report. Without the conversion
// the range stops at midnight on the 7th and quietly loses a day's trade.
want := time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC)
if !fs.lastReport.To.Equal(want) {
t.Fatalf("to = %s, want %s (exclusive end of the 7th)",
fs.lastReport.To, want)
}
}
func TestReportDefaultsAreSensible(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
do(t, s, "GET", "/api/reports/footfall", sess.Token, nil)
if fs.lastReport.Bucket != "day" || fs.lastReport.Timezone != "UTC" {
t.Fatalf("defaults: %+v", fs.lastReport)
}
if d := fs.lastReport.To.Sub(fs.lastReport.From); d < 28*24*time.Hour {
t.Fatalf("default window is %s, expected about a month", d)
}
}
func TestFootfallReportCarriesItsOwnConfidence(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.footfall = []FootfallPoint{{Bucket: "2026-08-01T00:00:00", Visitors: 9, New: 4, Returning: 5}}
fs.totals = Totals{UniqueVisitors: 7, Visits: 9, FractionBelowGate: 0.727, WorstSite: "Chennai"}
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/reports/footfall", sess.Token, nil)
var got FootfallReport
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
// A footfall figure from a badly placed camera is wrong in a way the figure
// itself cannot show. Measured on Office1 this was 0.727 - 73% of visitors
// seen and discarded - while the report looked like a quiet week.
if got.FractionBelowGate != 0.727 || got.WorstSite != "Chennai" {
t.Fatalf("confidence not reported: %+v", got)
}
// Unique people over the window, not the sum of the buckets: a customer who
// came twice is one person and two bucket-visitors.
if got.Total != 7 || got.Visits != 9 {
t.Fatalf("total=%d visits=%d, want 7 and 9", got.Total, got.Visits)
}
}
// ---------------------------------------------------------------- purchases
func TestPurchaseValidation(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
cases := []struct {
name string
body PurchaseInput
}{
{"no visitor", PurchaseInput{Amount: 100}},
// A refund is a different record with a different meaning. Allowing a
// negative here silently deflates the revenue figure the conversion
// report is judged by.
{"negative amount", PurchaseInput{VisitorID: visitorAID, Amount: -50}},
{"bad currency", PurchaseInput{VisitorID: visitorAID, Amount: 10, Currency: "rupees"}},
}
for _, tc := range cases {
if rec := do(t, s, "POST", "/api/purchases", sess.Token, tc.body); rec.Code != http.StatusBadRequest {
t.Errorf("%s: got %d, want 400", tc.name, rec.Code)
}
}
if rec := do(t, s, "POST", "/api/purchases", sess.Token,
PurchaseInput{VisitorID: visitorAID, Amount: 1499.50}); rec.Code != http.StatusNoContent {
t.Fatalf("valid purchase returned %d: %s", rec.Code, rec.Body.String())
}
if fs.lastPurchase.Currency != "INR" || fs.lastPurchase.Source != "manual" {
t.Fatalf("defaults not applied: %+v", fs.lastPurchase)
}
}
func TestAPurchaseForAVisitorWithNoSiteExplainsWhatToDo(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.purchaseErr = errNoSite
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/purchases", sess.Token,
PurchaseInput{VisitorID: visitorAID, Amount: 10})
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "site_id") {
t.Fatalf("the error does not say how to fix it: %s", rec.Body.String())
}
}
// ---------------------------------------------------------------- enrolment
func TestEnrolmentHandsOutCredentialsExactlyOnce(t *testing.T) {
s, fs := newServer(t)
code := "ABCDEF-123456"
fs.enrolment[hashHex(code)] = Enrolment{
ClientID: "client-acme", SiteID: "site-1", SiteName: "Chennai",
SiteSlug: "store1", MQTTUser: "acme.store1", MQTTPass: "broker-secret",
}
s.Bootstrap = BootstrapConfig{MQTTURL: "tls://mcp.loyaly.ai:8883", CACert: "-----BEGIN"}
rec := do(t, s, "POST", "/api/agent/enrol", "",
map[string]string{"site_token": "abcdef 123456"})
if rec.Code != http.StatusOK {
t.Fatalf("enrol failed: %d %s", rec.Code, rec.Body.String())
}
var got map[string]any
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
if got["mqtt_password"] != "broker-secret" || got["mqtt_username"] != "acme.store1" {
t.Fatalf("credentials missing: %v", got)
}
if got["models"] == nil {
t.Fatal("models must be an empty list, not null, so the agent can " +
"fall back to its own list without special-casing")
}
// Second use of the same code must fail: it is read aloud, pasted into
// chat and photographed.
again := do(t, s, "POST", "/api/agent/enrol", "",
map[string]string{"site_token": code})
if again.Code != http.StatusUnauthorized {
t.Fatalf("a spent code was accepted again: %d", again.Code)
}
}
func TestEnrolmentFailuresAreIndistinguishable(t *testing.T) {
s, _ := newServer(t)
rec := do(t, s, "POST", "/api/agent/enrol", "",
map[string]string{"site_token": "NOPE-NOPE-NOPE"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d", rec.Code)
}
body := rec.Body.String()
// Unknown, expired and already-used must read the same. The difference only
// helps somebody guessing codes; the operator's next step is identical.
for _, leak := range []string{"expired", "used", "unknown"} {
if strings.Contains(strings.ToLower(body), leak) {
t.Fatalf("the message says which failure it was: %s", body)
}
}
}
// ---------------------------------------------------------------- plumbing
func TestUnknownBodyFieldsAreRejected(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
req := httptest.NewRequest("POST", "/api/purchases",
strings.NewReader(`{"visitor_id":"`+visitorAID+`","amount":10,"client_id":"client-rival"}`))
req.Header.Set("Authorization", "Bearer "+sess.Token)
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
// Silently ignoring it would let a caller believe the field took effect.
if rec.Code != http.StatusBadRequest {
t.Fatalf("an unknown field was accepted: %d %s", rec.Code, rec.Body.String())
}
}
func TestWrongMethodIsNotAConfusing404(t *testing.T) {
s, _ := newServer(t)
rec := do(t, s, "GET", "/api/auth/login", "", nil)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("got %d, want 405", rec.Code)
}
}
func TestListEndpointsReturnAnEmptyArrayNotNull(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, path := range []string{"/api/visitors", "/api/sites",
visitorA + "/history"} {
rec := do(t, s, "GET", path, sess.Token, nil)
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
t.Errorf("%s returned %q - a null makes every caller handle "+
"two empty cases", path, got)
}
}
}
func TestServerErrorsDoNotLeakInternals(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.profileErr = errBoom
s.Log = nil // errors must not be echoed to the caller either way
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "PUT", visitorA + "/profile", sess.Token,
Profile{FullName: "Asha"})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("got %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "relation") ||
strings.Contains(rec.Body.String(), "boom") {
t.Fatalf("database detail reached the client: %s", rec.Body.String())
}
}
// A shop is one NAT address shared by every member of staff, so the per-IP
// limit has to be far looser than the per-account one or a single person
// fumbling their password locks the whole floor out.
func TestOneStaffMemberLockingThemselvesOutDoesNotLockOutTheShop(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.addUser("colleague@acme.com", "correct horse battery", UserRecord{
ID: "u9", ClientID: "client-acme", Role: "staff", Active: true,
})
s.Throttle = NewThrottle(3, time.Minute)
s.IPThrottle = NewThrottle(60, time.Minute)
// One person gets their own password wrong until they are locked out.
for i := 0; i < 4; i++ {
do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "wrong"})
}
locked := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
if locked.Code != http.StatusTooManyRequests {
t.Fatalf("the account was not locked: %d", locked.Code)
}
// Their colleague, on the same address, must still be able to work.
ok := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "colleague@acme.com", "password": "correct horse battery"})
if ok.Code != http.StatusOK {
t.Fatalf("a colleague on the same address was locked out too: %d %s",
ok.Code, ok.Body.String())
}
}
// The per-IP limiter still has to exist: without it one guess sprayed across
// every address at a site costs an attacker nothing.
func TestSprayingManyAddressesFromOneSourceIsStillStopped(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
s.Throttle = NewThrottle(10, time.Minute)
s.IPThrottle = NewThrottle(5, time.Minute)
for i := 0; i < 5; i++ {
do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "person" + itoa(i) + "@acme.com", "password": "Summer2026!"})
}
blocked := do(t, s, "POST", "/api/auth/login", "",
map[string]string{"email": "manager@acme.com", "password": "correct horse battery"})
if blocked.Code != http.StatusTooManyRequests {
t.Fatalf("spraying five addresses from one source was not throttled: %d",
blocked.Code)
}
}
// A mistyped id must not read as a server fault. `$1::uuid` on a malformed
// string is a Postgres cast error, so without a shape check every typo'd URL
// answers "something went wrong at our end" and sends an operator looking for
// an outage that is not there.
func TestMalformedVisitorIdsAreNotFoundNotServerErrors(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, id := range []string{
"not-a-uuid",
"98bf7587-4d55-4ae0-99e0",
"98bf7587-4d55-4ae0-99e0-de8c05dd3e7z",
"%27%3B%20DROP%20TABLE%20visits%3B%20--",
} {
hist := do(t, s, "GET", "/api/visitors/"+id+"/history", sess.Token, nil)
if hist.Code != http.StatusNotFound {
t.Errorf("history %q returned %d, want 404", id, hist.Code)
}
prof := do(t, s, "PUT", "/api/visitors/"+id+"/profile", sess.Token,
Profile{FullName: "Asha"})
if prof.Code != http.StatusNotFound {
t.Errorf("profile %q returned %d, want 404", id, prof.Code)
}
}
// A well-formed id must still reach the store.
ok := do(t, s, "PUT", "/api/visitors/98bf7587-4d55-4ae0-99e0-de8c05dd3e78/profile",
sess.Token, Profile{FullName: "Asha"})
if ok.Code != http.StatusNoContent {
t.Fatalf("a valid id was rejected: %d %s", ok.Code, ok.Body.String())
}
pur := do(t, s, "POST", "/api/purchases", sess.Token,
PurchaseInput{VisitorID: "not-a-uuid", Amount: 10})
if pur.Code != http.StatusNotFound {
t.Fatalf("purchase with a malformed id returned %d, want 404", pur.Code)
}
}

View File

@@ -0,0 +1,643 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
const siteMain = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
func base() time.Time { return time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC) }
// seedArrivals lays down n visits one second apart, each with a photo.
func seedArrivals(fs *fakeStore, n int) {
for i := 0; i < n; i++ {
fs.arrivals = append(fs.arrivals, Arrival{
VisitID: fmt.Sprintf("00000000-0000-0000-0000-%012d", i),
Seq: int64(i + 1),
OccurredAt: base().Add(time.Duration(i) * time.Second).Format(time.RFC3339Nano),
SiteID: siteMain,
Site: "Anna Nagar",
CameraID: "door",
VisitorID: fmt.Sprintf("11111111-0000-0000-0000-%012d", i),
Label: fmt.Sprintf("Visitor %d", i),
ImageKey: fmt.Sprintf("behavision/v2/acme/main/2026/09/02/%d.jpg", i),
})
}
}
func getPage(t *testing.T, s *Server, path, token string) ArrivalPage {
t.Helper()
rec := do(t, s, "GET", path, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s: got %d, body %s", path, rec.Code, rec.Body.String())
}
var page ArrivalPage
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
return page
}
// Four people walking through a door together is the case this endpoint was
// built for, and the one that used to take nine requests to render: a search
// that could not tell you who arrived, then one image call per person.
func TestFourPeopleArrivingTogetherComeBackInOneRequest(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
// All four in the same millisecond - a burst is exactly when this happens.
for i := 0; i < 4; i++ {
fs.arrivals = append(fs.arrivals, Arrival{
VisitID: fmt.Sprintf("00000000-0000-0000-0000-%012d", i),
Seq: int64(i + 1),
OccurredAt: base().Format(time.RFC3339Nano),
SiteID: siteMain, Site: "Anna Nagar",
VisitorID: fmt.Sprintf("11111111-0000-0000-0000-%012d", i),
ImageKey: fmt.Sprintf("k%d.jpg", i),
})
}
sess := login(t, s, "manager@acme.com", "correct horse battery")
page := getPage(t, s, "/api/visits", sess.Token)
if len(page.Arrivals) != 4 {
t.Fatalf("want 4 arrivals in one response, got %d", len(page.Arrivals))
}
for i, a := range page.Arrivals {
if !a.Image.Available || a.Image.URL == "" {
t.Errorf("arrival %d has no photo link: %+v", i, a.Image)
}
}
// Identical timestamps must still produce a usable cursor, or a burst
// wedges the feed forever at the same instant.
if page.Cursor == "" {
t.Fatal("a burst at one instant produced no cursor")
}
seq, err := decodeCursor(page.Cursor)
if err != nil {
t.Fatalf("cursor from a burst is unreadable: %v", err)
}
if seq != page.Arrivals[3].Seq {
t.Errorf("cursor should point at the LAST row of the burst, got %d want %d",
seq, page.Arrivals[3].Seq)
}
}
// The property the whole feed rests on: poll twice and you see every person
// exactly once, even when more arrive than fit in one page.
func TestCursorLosesNobodyAndRepeatsNobody(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedArrivals(fs, 25)
sess := login(t, s, "manager@acme.com", "correct horse battery")
seen := map[string]int{}
cursor := ""
for poll := 0; poll < 5; poll++ {
path := "/api/visits?limit=10"
if cursor != "" {
path += "&cursor=" + cursor
} else {
// Start from position zero so the walk covers every row rather
// than starting at the newest window.
path += "&cursor=" + encodeCursor(0) + "&limit=10"
}
page := getPage(t, s, path, sess.Token)
for _, a := range page.Arrivals {
seen[a.VisitID]++
}
cursor = page.Cursor
}
if len(seen) != 25 {
t.Fatalf("walked the feed and saw %d of 25 people", len(seen))
}
for id, n := range seen {
if n != 1 {
t.Errorf("visit %s delivered %d times, want exactly 1", id, n)
}
}
}
// An app that has just opened wants the last few arrivals, not the first few
// ever recorded - but still ascending, so its cursor handling is the same on
// the first poll as on every one after.
func TestFirstPollReturnsTheNewestWindowAscending(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedArrivals(fs, 25)
sess := login(t, s, "manager@acme.com", "correct horse battery")
page := getPage(t, s, "/api/visits?limit=5", sess.Token)
if len(page.Arrivals) != 5 {
t.Fatalf("want 5, got %d", len(page.Arrivals))
}
if got := page.Arrivals[0].VisitID; !strings.HasSuffix(got, "020") {
t.Errorf("first poll should start at the 21st of 25 rows, got %s", got)
}
for i := 1; i < len(page.Arrivals); i++ {
if page.Arrivals[i-1].OccurredAt >= page.Arrivals[i].OccurredAt {
t.Fatalf("feed is not ascending at %d", i)
}
}
}
// A quiet minute must not reset the feed. Handing back an empty cursor would
// make the next poll re-deliver the whole recent window.
func TestAnEmptyPollHandsTheCursorBack(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedArrivals(fs, 3)
sess := login(t, s, "manager@acme.com", "correct horse battery")
first := getPage(t, s, "/api/visits", sess.Token)
again := getPage(t, s, "/api/visits?cursor="+first.Cursor, sess.Token)
if len(again.Arrivals) != 0 {
t.Fatalf("nothing new arrived, got %d rows", len(again.Arrivals))
}
if again.Cursor != first.Cursor {
t.Errorf("empty poll moved the cursor: %q -> %q", first.Cursor, again.Cursor)
}
}
// The object key names a tenant's storage prefix and is the input to every
// signing call. It must be structurally incapable of reaching a client.
func TestTheObjectKeyIsNeverInTheResponse(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
seedArrivals(fs, 3)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/visits", sess.Token, nil)
body := rec.Body.String()
if strings.Contains(body, `"image_key"`) || strings.Contains(body, "ImageKey") {
t.Fatalf("the response carries an object key:\n%s", body)
}
// The signed URL legitimately contains the key; what must not appear is a
// bare key in its own field.
if !strings.Contains(body, "X-Amz-Signature") {
t.Fatalf("expected signed links in the response:\n%s", body)
}
}
// Photos are off by default across the product, so "no photo" is the normal
// case and must not read as a fault. Two different absences need two different
// sentences, because a shop can fix one of them and not the other.
func TestNoPhotoIsDataNotAnError(t *testing.T) {
t.Run("images switched off for the deployment", func(t *testing.T) {
s, fs := newServer(t)
s.Blob = nil
seedUser(fs)
seedArrivals(fs, 1)
sess := login(t, s, "manager@acme.com", "correct horse battery")
page := getPage(t, s, "/api/visits", sess.Token)
got := page.Arrivals[0].Image
if got.Available || got.Reason == "" {
t.Fatalf("want an unavailable photo with a reason, got %+v", got)
}
if !strings.Contains(got.Reason, "not storing") {
t.Errorf("reason should say the system stores no photos, got %q", got.Reason)
}
})
t.Run("this visit simply had none", func(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
seedArrivals(fs, 1)
fs.arrivals[0].ImageKey = ""
sess := login(t, s, "manager@acme.com", "correct horse battery")
page := getPage(t, s, "/api/visits", sess.Token)
got := page.Arrivals[0].Image
if got.Available {
t.Fatalf("there is no key, so there is no photo: %+v", got)
}
if !strings.Contains(got.Reason, "No photo") {
t.Errorf("want a per-visit reason, got %q", got.Reason)
}
})
}
// A visit with no visitor_id is a site reporting footfall without templates.
// It is a real person walking in and must appear, or the feed disagrees with
// the footfall report about how many people came.
func TestAnUnidentifiedVisitStillAppears(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
seedArrivals(fs, 1)
fs.arrivals[0].VisitorID = ""
fs.arrivals[0].Label = ""
sess := login(t, s, "manager@acme.com", "correct horse battery")
page := getPage(t, s, "/api/visits", sess.Token)
if len(page.Arrivals) != 1 {
t.Fatalf("an anonymous visit was dropped from the feed")
}
}
// A tablet polling every two seconds would write tens of thousands of audit
// rows a day and bury the one deliberate look an investigation is after.
func TestTheFeedWritesOneAuditRowPerPageNotPerPhoto(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
seedArrivals(fs, 6)
sess := login(t, s, "manager@acme.com", "correct horse battery")
getPage(t, s, "/api/visits", sess.Token)
fs.mu.Lock()
defer fs.mu.Unlock()
var views []AuditEntry
for _, a := range fs.audits {
if strings.HasPrefix(a.Action, "image.view") {
views = append(views, a)
}
}
if len(views) != 1 {
t.Fatalf("want exactly 1 audit row for a page of 6 photos, got %d", len(views))
}
if got := views[0].Detail["count"]; got != 6 {
t.Errorf("the audit row should record how many faces were surfaced, got %v", got)
}
}
// Nothing is audited when no face was actually shown - otherwise the log fills
// with rows recording that somebody looked at nothing.
func TestAQuietPollWritesNoAuditRow(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
getPage(t, s, "/api/visits", sess.Token)
fs.mu.Lock()
defer fs.mu.Unlock()
for _, a := range fs.audits {
if strings.HasPrefix(a.Action, "image.view") {
t.Fatalf("audited an image view with no images: %+v", a)
}
}
}
// The tenant comes from the session. A site_id in the query string is
// caller-controlled and is a cross-tenant read the moment it is trusted alone.
func TestTheFeedIsScopedToTheSessionsTenant(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedArrivals(fs, 3)
sess := login(t, s, "manager@acme.com", "correct horse battery")
getPage(t, s, "/api/visits?site_id="+siteMain, sess.Token)
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.arrivalQ.ClientID != "client-acme" {
t.Fatalf("query ran for client %q, want the session's own", fs.arrivalQ.ClientID)
}
}
func TestTheFeedNeedsASession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
if rec := do(t, s, "GET", "/api/visits", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401 without a token, got %d", rec.Code)
}
}
// A malformed cursor is the caller's, not a server fault, and the message has
// to tell a client how to recover - it cannot parse the cursor to fix it.
func TestARubbishCursorIsARecoverableBadRequest(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, bad := range []string{"not-base64!!", "Zm9v",
base64.RawURLEncoding.EncodeToString([]byte("v1:banana"))} {
rec := do(t, s, "GET", "/api/visits?cursor="+bad, sess.Token, nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("cursor %q: got %d, want 400", bad, rec.Code)
}
if !strings.Contains(rec.Body.String(), "poll again without one") {
t.Errorf("cursor %q: message does not say how to recover: %s", bad, rec.Body.String())
}
}
}
// The id goes into $4::uuid. A malformed one is a Postgres cast error, which
// surfaces as a 500 on a value the caller supplied.
// A cursor whose body is not a number must be refused rather than reaching the
// query, and a cursor from a FUTURE encoding must fail cleanly rather than being
// misread as a position that now means something else.
func TestOnlyAWellFormedV1CursorIsAccepted(t *testing.T) {
for _, bad := range []string{
base64.RawURLEncoding.EncodeToString([]byte("v1:not-a-number")),
base64.RawURLEncoding.EncodeToString([]byte("v1:-5")),
base64.RawURLEncoding.EncodeToString([]byte("v2:12")),
base64.RawURLEncoding.EncodeToString([]byte("12")),
base64.RawURLEncoding.EncodeToString([]byte("v1:'; DROP TABLE visits; --")),
} {
if _, err := decodeCursor(bad); err == nil {
t.Errorf("accepted a malformed cursor: %q", bad)
}
}
if seq, err := decodeCursor(encodeCursor(41)); err != nil || seq != 41 {
t.Fatalf("a cursor did not round-trip: %d %v", seq, err)
}
}
func TestASiteFilterMustBeASiteID(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/visits?site_id=main", sess.Token, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 for a non-uuid site_id, got %d", rec.Code)
}
}
func TestLimitIsCappedRatherThanRejected(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
// A client coming back from a tunnel asking for everything gets a big page
// and a fresh cursor, not an error it cannot recover from.
getPage(t, s, "/api/visits?limit=100000", sess.Token)
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.arrivalQ.Limit != maxArrivals {
t.Fatalf("limit %d, want it capped at %d", fs.arrivalQ.Limit, maxArrivals)
}
}
func TestADatabaseFailureIsAServerErrorNotAnEmptyFeed(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.arrivalsErr = errors.New("connection refused")
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/visits", sess.Token, nil)
if rec.Code != http.StatusInternalServerError {
// An empty 200 here would tell a shop nobody came in.
t.Fatalf("want 500, got %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "connection refused") {
t.Error("the database error leaked to the client")
}
}
func TestArrivalsIsAlwaysAListNeverNull(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/visits", sess.Token, nil)
if !strings.Contains(rec.Body.String(), `"arrivals":[]`) {
t.Fatalf("a quiet feed must be an empty list: %s", rec.Body.String())
}
}
// ---------------------------------------------------------------- streaming
func TestTheStreamSendsWhatIsAlreadyThereThenRespondsToTheDoorbell(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
s.Hub = NewHub()
seedUser(fs)
seedArrivals(fs, 2)
sess := login(t, s, "manager@acme.com", "correct horse battery")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest("GET", "/api/visits/stream", nil).WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+sess.Token)
rec := newStreamRecorder()
done := make(chan struct{})
go func() { defer close(done); s.Routes().ServeHTTP(rec, req) }()
// The first frame is the catch-up: a client connecting after people have
// walked in must not be blind until the next person arrives.
waitFor(t, rec, "event: arrivals")
if h := rec.Header().Get("Content-Type"); h != "text/event-stream" {
t.Errorf("Content-Type %q", h)
}
if h := rec.Header().Get("X-Accel-Buffering"); h != "no" {
t.Error("without this the proxy buffers the stream into a single response at the end")
}
if !strings.Contains(rec.body(), "id: ") {
t.Error("no SSE id, so a reconnect cannot resume via Last-Event-ID")
}
before := len(rec.body())
fs.mu.Lock()
fs.arrivals = append(fs.arrivals, Arrival{
VisitID: "00000000-0000-0000-0000-000000000099", Seq: 99,
OccurredAt: base().Add(time.Hour).Format(time.RFC3339Nano),
SiteID: siteMain, VisitorID: "11111111-0000-0000-0000-000000000099",
})
fs.mu.Unlock()
s.Hub.Notify("client-acme")
waitForGrowth(t, rec, before)
cancel()
<-done
}
// One process serves every tenant. A doorbell for another shop must not make
// this connection query, let alone emit anything.
func TestAnotherTenantsDoorbellIsIgnored(t *testing.T) {
s, fs := newServer(t)
s.Hub = NewHub()
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest("GET", "/api/visits/stream", nil).WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+sess.Token)
rec := newStreamRecorder()
done := make(chan struct{})
go func() { defer close(done); s.Routes().ServeHTTP(rec, req) }()
waitForSubscriber(t, s.Hub)
fs.mu.Lock()
fs.arrivals = append(fs.arrivals, Arrival{
VisitID: "00000000-0000-0000-0000-000000000001", Seq: 1,
OccurredAt: base().Format(time.RFC3339Nano), SiteID: siteMain,
})
queriesBefore := fs.arrivalCalls
fs.mu.Unlock()
s.Hub.Notify("client-someone-else")
time.Sleep(50 * time.Millisecond)
fs.mu.Lock()
after := fs.arrivalCalls
fs.mu.Unlock()
if after != queriesBefore {
t.Fatalf("another tenant's doorbell caused %d queries", after-queriesBefore)
}
cancel()
<-done
}
func TestTheStreamNeedsASession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
if rec := do(t, s, "GET", "/api/visits/stream", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rec.Code)
}
}
// A subscriber that is not released leaks a goroutine and a channel for the
// life of the process, on an endpoint mobile clients reconnect to all day.
func TestReleasingASubscriberRemovesIt(t *testing.T) {
h := NewHub()
_, release := h.Subscribe()
if h.Subscribers() != 1 {
t.Fatalf("want 1 subscriber, got %d", h.Subscribers())
}
release()
if h.Subscribers() != 0 {
t.Fatalf("subscriber leaked: %d still registered", h.Subscribers())
}
release() // must be safe twice: defer plus an early return is normal
}
// A doorbell is idempotent, so a busy listener that misses one loses nothing -
// but Notify must never block waiting for it, or one slow subscriber stalls
// ingest for the whole estate.
func TestNotifyNeverBlocksOnASlowSubscriber(t *testing.T) {
h := NewHub()
ch, release := h.Subscribe()
defer release()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 1000; i++ {
h.Notify("client-acme")
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Notify blocked - ingest would stall behind a slow reader")
}
if len(ch) != 1 {
t.Errorf("want the single pending doorbell, got %d", len(ch))
}
}
func TestNotifyOnANilHubIsSafe(t *testing.T) {
var h *Hub
h.Notify("client-acme") // a server assembled without one must still ingest
if h.Subscribers() != 0 {
t.Fatal("nil hub reported subscribers")
}
}
// ---------------------------------------------------------------- helpers
// streamRecorder is a ResponseWriter a test can read WHILE the handler is
// still writing to it. httptest.ResponseRecorder cannot be: the handler runs on
// its own goroutine for the life of the stream, so every Body.String() from the
// test is a data race that -race turns into a failure and, without it, into an
// occasional mystery.
//
// It implements Flusher because the handler refuses to stream without one - a
// recorder that silently lacked it would make these tests exercise the error
// path while appearing to pass.
type streamRecorder struct {
mu sync.Mutex
buf strings.Builder
hdr http.Header
code int
pushed chan struct{}
}
func newStreamRecorder() *streamRecorder {
return &streamRecorder{hdr: http.Header{}, code: 200,
pushed: make(chan struct{}, 64)}
}
func (r *streamRecorder) Header() http.Header { return r.hdr }
func (r *streamRecorder) Write(b []byte) (int, error) {
r.mu.Lock()
n, err := r.buf.Write(b)
r.mu.Unlock()
return n, err
}
func (r *streamRecorder) WriteHeader(code int) { r.code = code }
func (r *streamRecorder) Flush() {
// Signals the test that a frame is complete, so it can wait on an event
// rather than on a sleep long enough to hide a real stall.
select {
case r.pushed <- struct{}{}:
default:
}
}
func (r *streamRecorder) body() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.buf.String()
}
func waitFor(t *testing.T, rec *streamRecorder, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(rec.body(), want) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("never saw %q in the stream:\n%s", want, rec.body())
}
func waitForGrowth(t *testing.T, rec *streamRecorder, was int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(rec.body()) > was {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("the doorbell did not push a new arrival within 2s")
}
func waitForSubscriber(t *testing.T, h *Hub) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if h.Subscribers() > 0 {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("stream never subscribed to the hub")
}

View File

@@ -0,0 +1,331 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
const siteA = "aaaaaaaa-1111-2222-3333-444444444444"
// camPath addresses the camera the fake store creates for a given name.
func camPath(cameraID string) string { return "/api/cameras/" + fakeCameraUUID(cameraID) }
// ---------------------------------------------------------------- the boundary
// The single most important assertion in this file. An RTSP credential is a
// live path into the camera itself, and the only consumer that legitimately
// needs the plaintext is the agent for its own site.
func TestACameraPasswordIsNeverReturnedToAPerson(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
"camera_id": "entrance", "label": "Entrance",
"host": "192.168.0.138", "username": "admin", "password": "hunter2",
})
if rec.Code != http.StatusCreated {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "hunter2") {
t.Fatalf("the camera password came back:\n%s", rec.Body.String())
}
list := do(t, s, "GET", "/api/cameras", sess.Token, nil)
if strings.Contains(list.Body.String(), "hunter2") {
t.Fatalf("the camera password is in the list:\n%s", list.Body.String())
}
// The operator still has to be able to tell "no password set" from "a
// password is set and I am simply not being shown it".
if !strings.Contains(list.Body.String(), `"has_password":true`) {
t.Errorf("no indication a password is stored:\n%s", list.Body.String())
}
}
// The agent is the one caller that gets it, and only for its own site.
func TestTheAgentReceivesThePasswordItNeedsToConnect(t *testing.T) {
s, fs := newServer(t)
fs.addAgent("agent-token", AgentPrincipal{
AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA})
fs.agentCameras = []AgentCamera{{
CameraID: "entrance", Host: "192.168.0.138", Port: 554,
Username: "admin", Password: "hunter2", Enabled: true, Revision: 1,
}}
req := do(t, s, "GET", "/api/agent/cameras", "agent-token", nil)
if req.Code != http.StatusOK {
t.Fatalf("got %d: %s", req.Code, req.Body.String())
}
if !strings.Contains(req.Body.String(), "hunter2") {
t.Fatal("the agent did not get the password, so it cannot connect")
}
}
// An agent has no user, no role and no session. A person's token must not open
// the agent routes, and vice versa.
func TestAgentRoutesRefuseAUserSession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
for _, call := range [][2]string{
{"GET", "/api/agent/cameras"},
{"POST", "/api/agent/cameras"},
} {
rec := do(t, s, call[0], call[1], sess.Token, AgentCameraReport{})
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s: got %d, want 401", call[0], call[1], rec.Code)
}
}
}
func TestCameraRoutesNeedASession(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
if rec := do(t, s, "GET", "/api/cameras", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
}
// ---------------------------------------------------------------- editing
// The camera id is what visits are recorded against. Renaming it would orphan
// every visit already attributed to the old name.
func TestEditingACameraCannotRenameTheIdVisitsAreRecordedAgainst(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
"camera_id": "entrance", "host": "10.0.0.5"})
rec := do(t, s, "PATCH", camPath("entrance"), sess.Token, map[string]any{
"camera_id": "back-door", "label": "Back door"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var cam Camera
json.Unmarshal(rec.Body.Bytes(), &cam) //nolint:errcheck
if cam.CameraID != "entrance" {
t.Fatalf("the camera id was renamed to %q", cam.CameraID)
}
if cam.Label != "Back door" {
t.Errorf("the label should be editable, got %q", cam.Label)
}
}
// A blank field means "leave alone". Sending an empty password on every edit is
// how a camera loses its credential the first time somebody fixes a typo in the
// label.
func TestAnOmittedPasswordIsNotSentToTheStore(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
"camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"})
do(t, s, "PATCH", camPath("entrance"), sess.Token,
map[string]any{"label": "Front"})
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.lastSaved.Password != nil {
t.Fatalf("an edit that did not mention the password sent %q", *fs.lastSaved.Password)
}
}
// Staff can fill in a customer form; changing what a camera connects to is a
// different kind of act.
func TestStaffCannotChangeCameras(t *testing.T) {
s, fs := newServer(t)
fs.addUser("staff@acme.com", "correct horse battery", UserRecord{
ID: "u2", ClientID: "client-acme", Role: "staff", Active: true})
sess := login(t, s, "staff@acme.com", "correct horse battery")
for _, call := range [][2]string{
{"POST", "/api/sites/" + siteA + "/cameras"},
{"PATCH", camPath("entrance")},
{"DELETE", camPath("entrance")},
} {
rec := do(t, s, call[0], call[1], sess.Token, map[string]any{"host": "10.0.0.5"})
if rec.Code != http.StatusForbidden {
t.Errorf("%s %s: got %d, want 403", call[0], call[1], rec.Code)
}
}
// Reading is fine - staff need to see whether a camera is working.
if rec := do(t, s, "GET", "/api/cameras", sess.Token, nil); rec.Code != http.StatusOK {
t.Errorf("staff cannot see cameras at all: %d", rec.Code)
}
}
// ---------------------------------------------------------------- input
// The id ends up in an object key, a URL path and a topic segment.
func TestACameraIdCannotChangeWhatAPathOrTopicMeans(t *testing.T) {
for in, want := range map[string]string{
"Front Entrance": "front-entrance",
"ch0/0": "ch0-0",
"a+b#c": "a-b-c",
" Till 2 ": "till-2",
"../../etc": "etc",
"!!!": "",
} {
if got := cameraSlug(in); got != want {
t.Errorf("cameraSlug(%q) = %q, want %q", in, got, want)
}
}
}
// The message has to say what to type, not name a field.
func TestACameraWithNoAddressIsRefusedWithUsableAdvice(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
map[string]any{"camera_id": "entrance"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "192.168") {
t.Errorf("the message should show the shape of an address: %s", rec.Body.String())
}
}
func TestACameraNeedsAName(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
map[string]any{"host": "10.0.0.5"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
// A camera saved with its password silently dropped will not connect, and the
// operator could not tell that from a wrong password.
func TestSavingAPasswordWithNoEncryptionKeyFailsLoudly(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.saveCameraErr = ErrNoSecrets
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{
"camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"})
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "encryption key") {
t.Errorf("the message does not name the cause: %s", rec.Body.String())
}
}
// ---------------------------------------------------------------- snapshots
// Most deployments store no images at all, so "no picture" is the ordinary
// case and must not read as a fault.
func TestNoSnapshotIsDataNotAnError(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
var cams []Camera
json.Unmarshal(rec.Body.Bytes(), &cams) //nolint:errcheck
if cams[0].Snapshot.Available {
t.Fatal("claimed a picture with no key")
}
if cams[0].Snapshot.Reason == "" {
t.Fatal("no reason given for the missing picture")
}
}
// A snapshot is a frame of a shop floor: a short-lived signed link, never a
// stored URL, and never the raw key.
func TestASnapshotIsASignedLinkAndTheKeyStaysHidden(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance",
Snapshot: Image{Key: "behavision/v2/acme/main/snap.jpg"}}}
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
body := rec.Body.String()
if !strings.Contains(body, "X-Amz-Signature") {
t.Fatalf("no signed link: %s", body)
}
if strings.Contains(body, `"key"`) || strings.Contains(body, `"Key"`) {
t.Fatalf("the raw object key is in the response: %s", body)
}
}
// ---------------------------------------------------------------- adoption
// The agent may report its own site's state; the site comes from its
// credential, never from the body.
func TestAnAgentReportIsScopedByItsOwnCredential(t *testing.T) {
s, fs := newServer(t)
fs.addAgent("agent-token", AgentPrincipal{
AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA})
rec := do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{
State: []AgentCameraState{{CameraID: "entrance", Connected: true}},
Adopt: []AgentCamera{{CameraID: "Office Cam", Host: "192.168.0.138"}},
})
if rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
fs.mu.Lock()
defer fs.mu.Unlock()
if got := fs.lastCameraReport.Adopt[0].CameraID; got != "office-cam" {
t.Errorf("an adopted id was not normalised: %q", got)
}
}
// The create response must carry the same snapshot explanation the list does.
// Decorating a copy and serialising the original returned an empty snapshot
// object, so a freshly added camera showed no picture and no reason for it.
func TestACreatedCameraExplainsItsMissingPicture(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token,
map[string]any{"camera_id": "entrance", "host": "10.0.0.5"})
var cam Camera
if err := json.Unmarshal(rec.Body.Bytes(), &cam); err != nil {
t.Fatal(err)
}
if cam.Snapshot.Reason == "" {
t.Fatalf("no reason for the missing picture:\n%s", rec.Body.String())
}
}
// AgentPrincipal carries both the tenant's uuid and its human slug, and the
// slug is the one that reads correctly in a log line - which is exactly why it
// gets used by mistake in a query that wants the uuid. This shipped once and
// only failed against a real database.
func TestAnAgentReportIsStoredAgainstTheTenantUUIDNotTheSlug(t *testing.T) {
s, fs := newServer(t)
fs.addAgent("agent-token", AgentPrincipal{
AgentID: "a1",
ClientID: "8f1e0c2a-1111-2222-3333-444444444444", // the uuid
Client: "nearle", // the slug
SiteID: siteA, Site: "chennai",
})
do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{
State: []AgentCameraState{{CameraID: "entrance", Connected: true}}})
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.lastReportClient != "8f1e0c2a-1111-2222-3333-444444444444" {
t.Fatalf("stored against %q - a slug will not cast to uuid", fs.lastReportClient)
}
if fs.lastReportSite != siteA {
t.Fatalf("site %q", fs.lastReportSite)
}
}

View File

@@ -0,0 +1,234 @@
package api
import (
"strings"
"testing"
"time"
)
func stepsByName(steps []CheckStep) map[string]CheckStep {
out := map[string]CheckStep{}
for _, s := range steps {
out[s.Name] = s
}
return out
}
func healthySite() *SiteHealth {
now := time.Now().UTC()
return &SiteHealth{
SiteID: siteA, Name: "Chennai", Online: true,
LastHeartbeatAt: now.Add(-20 * time.Second).Format(time.RFC3339),
LastEventAt: now.Add(-3 * time.Minute).Format(time.RFC3339),
RecognitionModel: "w600k_r50.onnx",
CamerasUp: 2, CamerasTotal: 2, FractionBelowGate: 0.11,
}
}
func connectedCameras(n int) []Camera {
up := true
out := make([]Camera, n)
for i := range out {
out[i] = Camera{ID: "c", SiteID: siteA, CameraID: "entrance", Connected: &up}
}
return out
}
func TestAHealthySitePassesEveryStep(t *testing.T) {
steps := BuildSiteSteps(healthySite(), connectedCameras(2), time.Now().UTC())
for _, s := range steps {
if s.Status != "pass" {
t.Errorf("%q: %s — %s", s.Name, s.Status, s.Detail)
}
}
}
// The point of the whole check: a site whose PC is off cannot be judged on
// anything else, and printing guesses next to the real failure buries it.
func TestAnOfflinePCStopsTheRestBeingJudged(t *testing.T) {
site := healthySite()
site.Online = false
site.LastHeartbeatAt = time.Now().Add(-3 * time.Hour).UTC().Format(time.RFC3339)
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
by := stepsByName(steps)
if by["The shop's PC is online"].Status != "fail" {
t.Fatal("an offline PC was not reported as a failure")
}
for _, name := range []string{"Cameras are connected", "Cameras can recognise faces",
"Visits are reaching head office"} {
if got := by[name].Status; got != "unknown" {
t.Errorf("%q reported %q on an offline PC - it cannot be known", name, got)
}
}
}
// A PC that has never reported needs installing, not restarting. Different
// sentence, different action.
func TestAPCThatHasNeverReportedIsToldToInstall(t *testing.T) {
site := healthySite()
site.Online, site.LastHeartbeatAt = false, ""
by := stepsByName(BuildSiteSteps(site, nil, time.Now().UTC()))
advice := by["The shop's PC is online"].Advice
if !strings.Contains(advice, "enrolment code") {
t.Fatalf("advice does not say how to claim the PC: %q", advice)
}
}
// The Office1 case, and the reason this check exists at all: everything is
// plugged in, everything is online, and almost nobody is being recognised.
func TestASiteWhereMostVisitorsAreMissedFailsEvenThoughEverythingIsOnline(t *testing.T) {
site := healthySite()
site.FractionBelowGate = 0.727 // the number measured on Office1
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
by := stepsByName(steps)
if by["The shop's PC is online"].Status != "pass" {
t.Error("the PC is fine and should say so")
}
faces := by["Cameras can recognise faces"]
if faces.Status != "fail" {
t.Fatalf("73%% of visitors missed was reported as %q", faces.Status)
}
if !strings.Contains(faces.Detail, "73%") {
t.Errorf("the number is not shown: %q", faces.Detail)
}
if !strings.Contains(faces.Advice, "moving") {
t.Errorf("advice does not say what to do: %q", faces.Advice)
}
// And the site as a whole must not read as working.
ok := true
for _, s := range steps {
if s.Status != "pass" {
ok = false
}
}
if ok {
t.Fatal("a site missing 73% of its visitors was reported as working")
}
}
// A shop set up before opening has seen nobody. That is not a fault, and
// calling it one sends an installer looking for a problem that is not there.
func TestAShopThatHasSeenNobodyYetIsUnknownNotBroken(t *testing.T) {
site := healthySite()
site.LastEventAt = ""
site.FractionBelowGate = 0
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
if got := by["Cameras can recognise faces"].Status; got != "unknown" {
t.Errorf("a new shop reported %q, want unknown", got)
}
if got := by["Visits are reaching head office"].Status; got != "unknown" {
t.Errorf("no visits yet reported %q, want unknown", got)
}
// But it must still tell them how to prove it before opening.
if !strings.Contains(by["Cameras can recognise faces"].Advice, "walk-past") {
t.Error("no advice on how to prove the camera before the shop opens")
}
}
// Lost footfall can never be recovered, so it has to be visible rather than
// inferred from a report that is quietly short.
func TestDroppedVisitsAreAFailureAndSayTheyCannotBeRecovered(t *testing.T) {
site := healthySite()
site.Dropped = 412
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
send := by["Visits are reaching head office"]
if send.Status != "fail" {
t.Fatalf("lost visits reported as %q", send.Status)
}
if !strings.Contains(send.Detail, "412") {
t.Errorf("the count is not shown: %q", send.Detail)
}
if !strings.Contains(send.Advice, "cannot be recovered") {
t.Errorf("advice implies they might come back: %q", send.Advice)
}
}
// Recording but not sending is its own state: the shop is working, head office
// is blind, and the two look identical on a footfall report.
func TestABackedUpQueueIsAWarningNotAFailure(t *testing.T) {
site := healthySite()
site.Queued = 340
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
if got := by["Visits are reaching head office"].Status; got != "warn" {
t.Fatalf("a backed-up queue reported as %q", got)
}
}
func TestASiteWithNoCamerasSaysToAddOne(t *testing.T) {
by := stepsByName(BuildSiteSteps(healthySite(), nil, time.Now().UTC()))
cam := by["Cameras are connected"]
if cam.Status != "fail" {
t.Fatalf("no cameras reported as %q", cam.Status)
}
if !strings.Contains(cam.Advice, "Add a camera") {
t.Errorf("advice: %q", cam.Advice)
}
}
// A camera nobody has tried is not a camera that is down.
func TestCamerasNotYetTriedAreAWarningNotAFailure(t *testing.T) {
cams := []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}} // Connected nil
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
cam := by["Cameras are connected"]
if cam.Status != "warn" {
t.Fatalf("an untried camera reported as %q, want warn", cam.Status)
}
if !strings.Contains(cam.Detail, "none tried yet") {
t.Errorf("detail: %q", cam.Detail)
}
}
func TestADownCameraFails(t *testing.T) {
down := false
up := true
cams := []Camera{
{ID: "c1", SiteID: siteA, CameraID: "entrance", Connected: &up},
{ID: "c2", SiteID: siteA, CameraID: "till", Connected: &down},
}
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
if got := by["Cameras are connected"].Status; got != "fail" {
t.Fatalf("a down camera reported as %q", got)
}
}
// A running process is not a working engine: on a memory-starved box the large
// model loses the fallback chain and the process stays up regardless.
func TestAnEngineThatHasNotSaidWhichModelItLoadedIsAWarning(t *testing.T) {
site := healthySite()
site.RecognitionModel = ""
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
if got := by["Recognition is running"].Status; got != "warn" {
t.Fatalf("reported %q", got)
}
}
func TestHumanAgoReadsLikeAPersonWouldSayIt(t *testing.T) {
now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
ago time.Duration
want string
}{
{10 * time.Second, "just now"},
{20 * time.Minute, "20 min ago"},
{5 * time.Hour, "5 h ago"},
{80 * time.Hour, "3 days ago"},
} {
got := humanAgo(now.Add(-tc.ago).Format(time.RFC3339), now)
if got != tc.want {
t.Errorf("%s ago -> %q, want %q", tc.ago, got, tc.want)
}
}
if got := humanAgo("not a time", now); got != "at an unknown time" {
t.Errorf("unparseable -> %q", got)
}
}

View File

@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
func seedSite(fs *fakeStore) {
fs.sites = []SiteHealth{{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"}}
}
func TestAManagerCanGetACodeForTheirOwnShop(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedSite(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/enrolment-code", sess.Token,
map[string]any{"label": "counter PC"})
if rec.Code != http.StatusCreated {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out EnrolmentCode
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Code == "" || out.ExpiresAt.IsZero() {
t.Fatalf("no usable code came back: %s", rec.Body.String())
}
// Grouped for reading aloud - the installer is on the phone.
if !strings.Contains(out.Code, "-") {
t.Errorf("code is not grouped for dictation: %q", out.Code)
}
if out.SiteName != "TeNext Chennai" {
t.Errorf("the shop is not named back to the operator: %q", out.SiteName)
}
}
// Staff must not be able to mint one. The code is redeemed for the site's
// broker password, so it is a credential and not a convenience - and an
// instruction in a prompt or a hidden button is not a permission check.
func TestStaffCannotMintAnEnrolmentCode(t *testing.T) {
s, fs := newServer(t)
seedSite(fs)
fs.addUser("staff@acme.com", "correct horse battery", UserRecord{
ID: "u2", ClientID: "client-acme", Role: "staff", Active: true,
})
sess := login(t, s, "staff@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites/"+siteA+"/enrolment-code", sess.Token, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("staff minted a credential: %d %s", rec.Code, rec.Body.String())
}
}
// A shop belonging to somebody else is NOT FOUND, not forbidden: a tenant has
// no business learning that another tenant's shop exists.
func TestAnotherTenantsShopIsNotFound(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedSite(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
other := "bbbbbbbb-1111-2222-3333-444444444444"
rec := do(t, s, "POST", "/api/sites/"+other+"/enrolment-code", sess.Token, nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404: %s", rec.Code, rec.Body.String())
}
}
// A code is read aloud, photographed and pasted into chat on its way to a
// shop. A caller asking for a year of validity gets a month.
func TestCodeLifetimeIsCapped(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedSite(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
do(t, s, "POST", "/api/sites/"+siteA+"/enrolment-code", sess.Token,
map[string]any{"days": 3650})
if got := fs.lastCodeTTL.Hours(); got > 30*24 {
t.Fatalf("ttl was %v, want at most 30 days", fs.lastCodeTTL)
}
// And a code records who minted it - it hands out a broker password.
if fs.lastCodeActor == "" {
t.Error("the code was not attributed to the person who asked for it")
}
}

View File

@@ -0,0 +1,588 @@
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
}

View File

@@ -0,0 +1,133 @@
package api
import (
"net/http"
"strings"
)
// Platform administration: creating the tenants everything else belongs to.
//
// Deliberately NOT public registration. An open endpoint that mints tenants is
// a far larger thing to have to secure than one behind an account that already
// exists, and a stranger creating a tenant on this platform is not a customer -
// it is a database row nobody asked for holding a place in a table every query
// joins against.
//
// The `provision` CLI still exists and still works. It is the recovery path:
// creating the FIRST platform admin cannot itself require being signed in as
// one, and a bootstrap that only works over HTTP is a bootstrap that fails
// exactly when HTTP is what is broken.
// adminOnly gates the routes below on a platform administrator.
//
// A platform admin is defined by having NO client - the scope is the absence,
// not a flag - so this checks both. A tenant-scoped account with the role
// somehow set to "admin" would otherwise read every customer of every client.
func (s *Server) adminOnly(next http.HandlerFunc) http.HandlerFunc {
return s.authed(func(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.IsAdmin() || p.ClientID != "" {
// 404, not 403. A tenant user has no business knowing that a
// platform-administration surface exists at all.
writeErr(w, http.StatusNotFound, "not_found", "No such page.")
return
}
next(w, r)
})
}
func (s *Server) handleListClients(w http.ResponseWriter, r *http.Request) {
rows, err := s.Store.ListClients(r.Context())
if err != nil {
s.serverError(w, "list clients", err)
return
}
if rows == nil {
rows = []ClientRow{}
}
writeJSON(w, http.StatusOK, rows)
}
func (s *Server) handleCreateClient(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
var in NewClientInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
in.CompanyName = clip(trim(in.CompanyName), 200)
in.OwnerName = clip(trim(in.OwnerName), 200)
in.OwnerEmail = strings.ToLower(trim(in.OwnerEmail))
in.Slug = slugify(in.Slug)
if in.Slug == "" {
// Derived from the company name when not given, because the slug is a
// technical detail (it becomes the MQTT topic prefix) and asking an
// operator to invent one is asking them to get it wrong.
in.Slug = slugify(in.CompanyName)
}
switch {
case in.CompanyName == "":
badRequest(w, "the company needs a name")
return
case in.OwnerEmail == "":
badRequest(w, "an owner email is required - without one nobody can sign in")
return
case in.Slug == "":
badRequest(w, "the company name has no letters or digits to build a short name from")
return
}
out, err := s.Store.CreateClientWithOwner(r.Context(), in)
if err != nil {
// Duplicate slug and duplicate email are the two an operator can act
// on, and both are ordinary typing mistakes rather than faults.
if msg, ok := conflictMessage(err); ok {
writeErr(w, http.StatusConflict, "conflict", msg)
return
}
s.serverError(w, "create client", err)
return
}
// Creating a tenant is rare and consequential; it should leave a trace with
// a name against it.
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: "client.create",
Entity: "client", EntityID: out.ClientID,
Detail: map[string]any{"slug": out.Slug, "owner": out.OwnerEmail},
})
writeJSON(w, http.StatusCreated, out)
}
// slugify turns "Nearle Retail Pvt Ltd" into "nearle-retail-pvt-ltd".
//
// The result becomes an MQTT topic segment, so it is restricted to characters
// that cannot change what a topic means: no '/', no '+', no '#'.
func slugify(s string) string {
var b strings.Builder
lastDash := true // never start with a dash
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case !lastDash:
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func conflictMessage(err error) (string, bool) {
msg := err.Error()
switch {
case strings.Contains(msg, "clients_slug_key"):
return "A company with that short name already exists - choose another.", true
case strings.Contains(msg, "app_users_email_idx"):
return "That email address already has an account.", true
}
return "", false
}

View File

@@ -0,0 +1,100 @@
package api
import (
"net/http"
"strings"
"github.com/loyaly/behavision-server/internal/auth"
)
// handleEnrol turns an anonymous install into a known site.
//
// The installer ships with no credentials of any kind, so a leaked build hands
// out nothing at all. An operator types a one-shot code once; the server
// answers with the broker login for exactly one site and marks the code spent.
//
// Deliberately NOT behind a session: the PC doing this has no user signed in
// yet, and requiring one would mean shipping a password to every shop that
// installs the software.
func (s *Server) handleEnrol(w http.ResponseWriter, r *http.Request) {
var body struct {
SiteToken string `json:"site_token"`
Device string `json:"device"`
}
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
token := trim(body.SiteToken)
if token == "" {
badRequest(w, "site_token is required")
return
}
token = auth.NormalizeCode(token)
en, err := s.Store.RedeemEnrolment(r.Context(), auth.HashToken(token))
if err != nil {
// One message for unknown, expired and already-used. The difference is
// only useful to somebody guessing codes, and an operator's next step
// is the same in all three cases: ask for a new one.
s.logf("enrolment refused: %v", err)
writeErr(w, http.StatusUnauthorized, "bad_token",
"That installation code is not valid. Ask for a new one.")
return
}
// Mint the agent's own HTTPS credential now, while we have a proven
// one-shot token in hand. Separate from the broker password because they
// authenticate different things, so rotating one must not break the other.
agentToken, err := auth.NewToken()
if err != nil {
s.serverError(w, "mint agent token", err)
return
}
if err := s.Store.SetAgentAPIToken(r.Context(), en.AgentID, agentToken.Hash); err != nil {
s.serverError(w, "store agent token", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: en.ClientID, ActorKind: "agent",
Action: "agent.enrol", Entity: "site", EntityID: en.SiteID,
Detail: map[string]any{"device": clip(trim(body.Device), 120)},
})
models := s.Bootstrap.Models
if models == nil {
// An empty list, not null: the agent then falls back to its own built-in
// download list rather than treating the field as missing.
models = []ModelRef{}
}
writeJSON(w, http.StatusOK, map[string]any{
"site_id": en.SiteID,
"site_name": en.SiteName,
"site_slug": en.SiteSlug,
// Derived from the broker username rather than looked up separately,
// because the agent uses <client>.<site> as its topic prefix and the
// broker's ACL is written against that exact username. Deriving it
// makes the two equal by construction; a second lookup could drift.
"client_slug": clientSlugOf(en.MQTTUser),
"topic_prefix": "bv/" + en.MQTTUser,
"mqtt_url": s.Bootstrap.MQTTURL,
"mqtt_username": en.MQTTUser,
"mqtt_password": en.MQTTPass,
// Used for HTTPS calls the PC makes on its own behalf, such as asking
// for an image upload URL. Shown once and never returned again.
"agent_token": agentToken.Plain,
"ca_cert": s.Bootstrap.CACert,
"models": models,
})
}
// clientSlugOf pulls the tenant out of a broker username of the form
// "<client>.<site>". Split on the FIRST dot only in the sense that a username
// always has exactly one - the same rule contract.ParseTopic enforces on the
// way back in.
func clientSlugOf(brokerUser string) string {
if i := strings.Index(brokerUser, "."); i > 0 {
return brokerUser[:i]
}
return ""
}

View File

@@ -0,0 +1,326 @@
package api
import (
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const (
// A poller asking for more than this is either paging history through the
// wrong endpoint or has lost its cursor. Capped rather than rejected: a
// mobile app coming back from a tunnel should get a big catch-up page and
// a fresh cursor, not an error it has no way to recover from.
maxArrivals = 200
defaultArrivals = 50
// How often a live stream re-queries even if no doorbell rings. This is the
// safety net for a second server instance whose ingest this process cannot
// hear, so it is slow on purpose - it is the fallback, not the mechanism.
streamFallback = 15 * time.Second
// SSE comment sent on an idle connection. Mobile networks and reverse
// proxies both close a stream that has been silent for a minute or two,
// and a client that reconnects every ninety seconds is a client that
// re-queries constantly.
streamKeepalive = 20 * time.Second
)
// handleArrivals is the live feed: who walked in, with their photos, in one
// request.
//
// This is the endpoint a mobile app or a shop-floor screen actually needs, and
// it is the one thing the API could not previously answer. `GET /api/visitors`
// searches a customer list by name; it cannot tell you that four people just
// came through the door, and until it could, a client had no way to know which
// customer ids to ask about.
func (s *Server) handleArrivals(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
q := ArrivalQuery{
ClientID: p.ClientID,
SiteID: trim(r.URL.Query().Get("site_id")),
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
}
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
badRequest(w, "site_id must be a site identifier")
return
}
// The site is still filtered by client_id in SQL as well. A site_id from
// the query string is caller-controlled, and this is a read of other
// people's customers if it is ever trusted on its own.
if cur := trim(r.URL.Query().Get("cursor")); cur != "" {
seq, err := decodeCursor(cur)
if err != nil {
badRequest(w, "that cursor is not one of ours - drop it and poll again without one")
return
}
q.AfterSeq = &seq
} else if since := trim(r.URL.Query().Get("since")); since != "" {
at, err := time.Parse(time.RFC3339, since)
if err != nil {
badRequest(w, "since must look like 2026-09-02T10:30:00Z")
return
}
at = at.UTC()
q.Since = &at
}
page, err := s.arrivalPage(r, q)
if err != nil {
s.serverError(w, "arrivals", err)
return
}
writeJSON(w, http.StatusOK, page)
}
// arrivalPage runs the query, attaches photos and returns the next cursor.
// Shared by the poll and the stream so the two cannot answer differently.
func (s *Server) arrivalPage(r *http.Request, q ArrivalQuery) (ArrivalPage, error) {
rows, err := s.Store.Arrivals(r.Context(), q)
if err != nil {
return ArrivalPage{}, err
}
s.attachImages(r, rows)
page := ArrivalPage{
Arrivals: rows,
PolledAt: s.now().UTC().Format(time.RFC3339Nano),
}
if page.Arrivals == nil {
// An empty list, never null. A client looping over the response should
// not have to special-case a quiet minute.
page.Arrivals = []Arrival{}
}
if n := len(rows); n > 0 {
// The LAST row, and the rows are ascending by position, so this is the
// highest position the caller has now seen.
page.Cursor = encodeCursor(rows[n-1].Seq)
} else if q.AfterSeq != nil {
// Nothing new. Hand the caller its own position back rather than an
// empty string, so a poll that returns nothing does not reset the feed
// to the beginning on the next request.
page.Cursor = encodeCursor(*q.AfterSeq)
}
return page, nil
}
// attachImages swaps each row's object key for a short-lived signed link.
//
// One audit row for the whole page, not one per photo. Every read of a face is
// worth recording - "who looked at my customers" has to be answerable - but a
// tablet polling this feed every two seconds would write tens of thousands of
// rows a day and bury the single deliberate look that an investigation is
// actually after. The row records how many faces were surfaced and to whom,
// which is the fact worth keeping.
func (s *Server) attachImages(r *http.Request, rows []Arrival) {
p := PrincipalFrom(r.Context())
seen := make([]string, 0, len(rows))
for i := range rows {
key := rows[i].ImageKey
rows[i].ImageKey = ""
switch {
case s.Blob == nil:
rows[i].Image.Reason = "This system is not storing customer photos."
case key == "":
rows[i].Image.Reason = "No photo was captured for this visit."
default:
url, err := s.Blob.PresignGet(key, viewTTL)
if err != nil {
// Log it, but never fail the feed over a picture. The visit is
// the number the customer pays for; the photo is decoration on
// top of it. This is the same rule the agent follows when an
// upload fails.
s.logf("ERROR presign arrival image: %v", err)
rows[i].Image.Reason = "That photo could not be loaded."
continue
}
rows[i].Image = Image{Available: true, URL: url,
ExpiresIn: int(viewTTL.Seconds())}
if rows[i].VisitorID != "" {
seen = append(seen, rows[i].VisitorID)
}
}
}
if len(seen) == 0 {
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "image.view.feed", Entity: "visits",
Detail: map[string]any{"count": len(seen), "visitor_ids": seen},
})
}
// handleArrivalStream is the same feed pushed instead of polled.
//
// Server-sent events rather than websockets: this direction is one-way, SSE is
// stdlib with no dependency, it survives the reverse proxy in front of this
// server unchanged, and browsers and mobile HTTP clients reconnect it on their
// own. A websocket would buy bidirectionality that nothing here wants.
func (s *Server) handleArrivalStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
// Without flushing this is not a stream, it is a response that arrives
// at the end of the day. Say so rather than appearing to work.
writeErr(w, http.StatusInternalServerError, "server_error",
"streaming is not available on this connection")
return
}
p := PrincipalFrom(r.Context())
q := ArrivalQuery{
ClientID: p.ClientID,
SiteID: trim(r.URL.Query().Get("site_id")),
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
}
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
badRequest(w, "site_id must be a site identifier")
return
}
// Last-Event-ID is what the browser's EventSource resends automatically on
// a dropped connection, so honouring it is what makes a reconnect lossless
// without the client writing any recovery code. ?cursor= is the same thing
// for a native client that cannot set the header.
cur := trim(r.Header.Get("Last-Event-ID"))
if cur == "" {
cur = trim(r.URL.Query().Get("cursor"))
}
if cur != "" {
if seq, err := decodeCursor(cur); err == nil {
q.AfterSeq = &seq
}
// A cursor we cannot read is not worth failing a reconnect over: the
// client falls back to the recent window, which is the same thing it
// would get on a fresh connection.
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
// Traefik and nginx both buffer by default, which turns an event stream
// into a file that arrives when the connection closes.
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
bell, release := s.hub().Subscribe()
defer release()
ctx := r.Context()
fallback := time.NewTicker(streamFallback)
defer fallback.Stop()
keepalive := time.NewTicker(streamKeepalive)
defer keepalive.Stop()
// Send whatever is already there before waiting for a doorbell, so a client
// that connects after people have walked in is not blind until the next
// one does.
send := func() bool {
page, err := s.arrivalPage(r, q)
if err != nil {
s.logf("ERROR arrival stream: %v", err)
// Keep the connection: a transient database error should not log
// a shop's screen out and start a reconnect storm across an estate.
return true
}
if len(page.Arrivals) == 0 {
return true
}
if page.Cursor != "" {
// The SSE id becomes the client's Last-Event-ID, so the cursor
// rides the protocol's own reconnect machinery instead of needing
// application-level recovery.
fmt.Fprintf(w, "id: %s\n", page.Cursor)
// Advance our own position from the rows we just sent, so the next
// wake-up asks for what comes after them.
last := page.Arrivals[len(page.Arrivals)-1].Seq
q.AfterSeq = &last
}
fmt.Fprint(w, "event: arrivals\ndata: ")
if err := writeCompactJSON(w, page); err != nil {
return false
}
fmt.Fprint(w, "\n\n")
flusher.Flush()
return true
}
if !send() {
return
}
for {
select {
case <-ctx.Done():
return
case id, open := <-bell:
if !open {
return
}
// One process serves every tenant, so a doorbell for somebody
// else's shop must not cost this connection a query.
if id != p.ClientID {
continue
}
if !send() {
return
}
case <-fallback.C:
if !send() {
return
}
case <-keepalive.C:
// A comment line. Keeps proxies and mobile radios from deciding
// the connection is dead, and is ignored by every SSE client.
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
func (s *Server) hub() *Hub {
if s.Hub == nil {
// A server built without one still streams; it just has no doorbell,
// so it falls back to the slow tick. A nil map panic on an endpoint
// somebody forgot to wire is a worse outcome than a slower feed.
return nil
}
return s.Hub
}
// ---------------------------------------------------------------- cursors
// Cursors are opaque on purpose, and base64 is what makes them look it. A
// caller that reads one starts depending on the ordering column, and changing
// that later then breaks every deployed mobile app rather than just this file -
// which is exactly what happened once already, when the feed was ordered by a
// timestamp and a random uuid.
func encodeCursor(seq int64) string {
return base64.RawURLEncoding.EncodeToString(
[]byte("v1:" + strconv.FormatInt(seq, 10)))
}
func decodeCursor(s string) (int64, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return 0, fmt.Errorf("cursor is not base64: %w", err)
}
// The version prefix is what lets the ordering change again without
// silently misreading cursors already held by deployed clients: an old
// cursor fails to parse and the client restarts cleanly from the recent
// window, rather than resuming at a position that now means something else.
body, ok := strings.CutPrefix(string(raw), "v1:")
if !ok {
return 0, fmt.Errorf("cursor is not a v1 cursor")
}
seq, err := strconv.ParseInt(body, 10, 64)
if err != nil || seq < 0 {
return 0, fmt.Errorf("cursor position is not a number")
}
return seq, nil
}

View File

@@ -0,0 +1,117 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"github.com/loyaly/behavision-server/internal/auth"
)
// The assistant, as an HTTP route.
//
// Session-authenticated like every other person-facing endpoint, and the
// principal it derives is handed to every tool the model calls - so the
// assistant can only ever see what the person asking could already see.
// Assistant is what the API needs from the assistant package.
//
// Declared HERE with the api package's own types, because `assistant` imports
// `api` for the report and camera shapes - so the dependency can only run one
// way, and main.go supplies a small adapter. That also makes the handler
// testable with no API key and no network call.
type Assistant interface {
Configured() bool
Ask(ctx context.Context, p auth.Principal, history []AssistantTurn) (AssistantAnswer, error)
}
// ErrAssistantOff is returned when no API key is configured. A supported
// state, not a fault.
var ErrAssistantOff = errors.New("the assistant is not switched on for this server")
// ErrAssistantMisconfigured means the credentials are present but incomplete -
// today, an identity-linked API key with no workspace id.
var ErrAssistantMisconfigured = errors.New("the assistant is configured incorrectly")
type AssistantTurn struct {
Role string `json:"role"`
Text string `json:"text"`
}
type AssistantAnswer struct {
Text string `json:"text"`
Used []string `json:"used,omitempty"`
}
// AssistantRequest is one question plus the conversation so far. The client
// holds the history: this server keeps no chat state, so there is no per-user
// transcript sitting in a database that nobody agreed to.
type AssistantRequest struct {
History []AssistantTurn `json:"history"`
}
const (
// A conversation longer than this is not a support question any more, and
// every turn is resent on every request.
maxAssistantTurns = 24
maxQuestionChars = 2000
)
func (s *Server) handleAssistant(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if s.Assistant == nil || !s.Assistant.Configured() {
// 501, not 500. "This deployment has no assistant" is a supported
// configuration; the UI hides the panel rather than showing an error.
writeErr(w, http.StatusNotImplemented, "assistant_off",
"The assistant is not switched on for this server.")
return
}
var body AssistantRequest
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
if len(body.History) == 0 {
badRequest(w, "ask a question")
return
}
if len(body.History) > maxAssistantTurns {
// Keep the most recent turns rather than refusing: a long conversation
// is a person still trying to solve their problem.
body.History = body.History[len(body.History)-maxAssistantTurns:]
}
for i := range body.History {
body.History[i].Text = clip(trim(body.History[i].Text), maxQuestionChars)
if body.History[i].Role != "assistant" {
body.History[i].Role = "user"
}
}
if strings.TrimSpace(body.History[len(body.History)-1].Text) == "" {
badRequest(w, "ask a question")
return
}
answer, err := s.Assistant.Ask(r.Context(), p, body.History)
if err != nil {
if errors.Is(err, ErrAssistantOff) {
writeErr(w, http.StatusNotImplemented, "assistant_off",
"The assistant is not switched on for this server.")
return
}
if errors.Is(err, ErrAssistantMisconfigured) {
// Told to the operator, not swallowed. "Something went wrong at our
// end" is true and useless when the fix is one environment
// variable, and this failure happens on the very first request so
// it is exactly when a clear message is worth most.
s.logf("ERROR assistant: %v", err)
writeErr(w, http.StatusServiceUnavailable, "assistant_misconfigured",
"The assistant is switched on but not configured correctly. "+
"This API key needs ANTHROPIC_WORKSPACE_ID set on the server.")
return
}
s.serverError(w, "assistant", err)
return
}
writeJSON(w, http.StatusOK, answer)
}

View File

@@ -0,0 +1,206 @@
package api
import (
"net/http"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Device string `json:"device"`
}
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
email := auth.NormalizeEmail(body.Email)
// Two limiters, at very different sizes. Per-account stops somebody working
// through a password list against one known address; per-IP is a much
// looser backstop against spraying one guess across many addresses, because
// an entire shop shares a single NAT address and a tight limit there locks
// out the whole staff when one person mistypes.
perUser, perIP := s.throttles()
ipKey, userKey := clientIP(r), email
if !perIP.Allow(ipKey) || !perUser.Allow(userKey) {
writeErr(w, http.StatusTooManyRequests, "too_many_attempts",
"Too many sign-in attempts. Wait a few minutes and try again.")
return
}
rec, err := s.Store.UserByEmail(r.Context(), email)
if err != nil {
s.serverError(w, "login lookup", err)
return
}
// Verify unconditionally, against a dummy hash when the address is unknown.
// Returning early on "no such user" makes login response time a membership
// oracle for your customer's staff directory.
hash := rec.PasswordHash
if !rec.Found || !rec.Active || hash == "" {
hash = auth.DummyHash
}
ok := auth.VerifyPassword(hash, body.Password)
if !ok || !rec.Found || !rec.Active {
perIP.Fail(ipKey)
perUser.Fail(userKey)
// One message for every failure. "No such account" and "wrong password"
// are the same answer to anyone who is not already the account holder.
writeErr(w, http.StatusUnauthorized, "bad_credentials",
"Email or password is incorrect.")
return
}
// Cleared on success, so one forgotten password in the morning does not
// lock a shop out at lunchtime.
perIP.Reset(ipKey)
perUser.Reset(userKey)
sess, err := s.mint(r, rec, body.Device)
if err != nil {
s.serverError(w, "create session", err)
return
}
if err := s.Store.TouchUserLogin(r.Context(), rec.ID); err != nil {
// Not fatal. Failing a successful login because a bookkeeping column
// would not update locks people out for nothing.
s.logf("WARN could not record last_login for %s: %v", rec.ID, err)
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user",
Action: "auth.login", Entity: "session",
Detail: map[string]any{"device": trim(body.Device)},
})
writeJSON(w, http.StatusOK, sess)
}
func (s *Server) mint(r *http.Request, rec UserRecord, device string) (Session, error) {
access, err := auth.NewToken()
if err != nil {
return Session{}, err
}
refresh, err := auth.NewToken()
if err != nil {
return Session{}, err
}
now := s.now()
ns := NewSession{
UserID: rec.ID,
ClientID: rec.ClientID,
AccessHash: access.Hash,
RefreshHash: refresh.Hash,
AccessExpiry: now.Add(auth.AccessTTL),
RefreshExp: now.Add(auth.RefreshTTL),
Device: clip(trim(device), 120),
}
if err := s.Store.CreateSession(r.Context(), ns); err != nil {
return Session{}, err
}
return Session{
Token: access.Plain,
RefreshToken: refresh.Plain,
ExpiresAt: ns.AccessExpiry.UTC().Format(time.RFC3339),
User: User{
ID: rec.ID, Email: rec.Email, FullName: rec.FullName,
Role: rec.Role, ClientID: rec.ClientID, Client: rec.ClientName,
},
}, nil
}
// handleRefresh swaps a refresh token for a new pair.
//
// The old refresh token is invalidated in the same statement that issues the
// new one. Leaving it usable would mean a token copied off a resold shop PC
// keeps working forever alongside the real one.
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
var body struct {
RefreshToken string `json:"refresh_token"`
Device string `json:"device"`
}
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
if trim(body.RefreshToken) == "" {
badRequest(w, "refresh_token is required")
return
}
p, expires, err := s.Store.SessionByRefresh(r.Context(),
auth.HashToken(body.RefreshToken))
if err != nil {
unauthorized(w, "Please sign in again.")
return
}
if s.now().After(expires) {
unauthorized(w, "Please sign in again.")
return
}
access, err := auth.NewToken()
if err != nil {
s.serverError(w, "refresh mint", err)
return
}
refresh, err := auth.NewToken()
if err != nil {
s.serverError(w, "refresh mint", err)
return
}
now := s.now()
ns := NewSession{
UserID: p.UserID,
ClientID: p.ClientID,
AccessHash: access.Hash,
RefreshHash: refresh.Hash,
AccessExpiry: now.Add(auth.AccessTTL),
// The refresh window slides. A shop PC that is used every day never has
// to be logged in again; one left in a cupboard for two months does.
RefreshExp: now.Add(auth.RefreshTTL),
Device: clip(trim(body.Device), 120),
}
if err := s.Store.RotateSession(r.Context(), p.SessionID, ns); err != nil {
s.serverError(w, "rotate session", err)
return
}
writeJSON(w, http.StatusOK, Session{
Token: access.Plain,
RefreshToken: refresh.Plain,
ExpiresAt: ns.AccessExpiry.UTC().Format(time.RFC3339),
User: User{
ID: p.UserID, Email: p.Email, FullName: p.FullName,
Role: p.Role, ClientID: p.ClientID, Client: p.ClientName,
},
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if err := s.Store.RevokeSession(r.Context(), p.SessionID); err != nil {
s.serverError(w, "revoke session", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "auth.logout", Entity: "session", EntityID: p.SessionID,
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, User{
ID: p.UserID, Email: p.Email, FullName: p.FullName,
Role: p.Role, ClientID: p.ClientID, Client: p.ClientName,
})
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}

View File

@@ -0,0 +1,274 @@
package api
import (
"errors"
"net/http"
"strings"
"time"
)
// Cameras, onboarded from head office instead of from the shop floor.
//
// The shop PC remains the thing that CONNECTS to a camera - it is on the same
// LAN and nothing else can be - so these routes write desired state that the
// agent pulls and applies. Two audiences, two shapes: a tenant never receives
// a camera password, and an agent receives one only for its own site.
// A snapshot is refreshed every minute or so, so a link outliving that is
// pointless; short enough that one in a screenshot is worthless by the time
// anyone reads it.
const snapshotTTL = 5 * time.Minute
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
siteID := trim(r.URL.Query().Get("site_id"))
if siteID != "" && !looksLikeUUID(siteID) {
badRequest(w, "site_id must be a site identifier")
return
}
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
if err != nil {
s.serverError(w, "cameras", err)
return
}
if cams == nil {
cams = []Camera{}
}
s.attachSnapshots(cams)
writeJSON(w, http.StatusOK, cams)
}
// attachSnapshots swaps each camera's object key for a signed link.
//
// A snapshot is a frame of a shop floor, so it gets the same treatment as a
// face: a short-lived signed URL, never a stored one. Absence is data - most
// deployments store no images at all, and a camera that is merely new has no
// frame yet.
func (s *Server) attachSnapshots(cams []Camera) {
for i := range cams {
key := cams[i].Snapshot.Key
cams[i].Snapshot.Key = ""
switch {
case s.Blob == nil:
cams[i].Snapshot.Reason = "This system is not storing images."
case key == "":
cams[i].Snapshot.Reason = "No picture from this camera yet."
default:
url, err := s.Blob.PresignGet(key, snapshotTTL)
if err != nil {
s.logf("ERROR presign snapshot: %v", err)
cams[i].Snapshot.Reason = "That picture could not be loaded."
continue
}
cams[i].Snapshot = Image{Available: true, URL: url,
ExpiresIn: int(snapshotTTL.Seconds())}
}
}
}
func (s *Server) handleCreateCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
siteID := r.PathValue("site")
if !looksLikeUUID(siteID) {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
var in CameraInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
id := ""
if in.CameraID != nil {
id = cameraSlug(*in.CameraID)
}
if id == "" && in.Label != nil {
id = cameraSlug(*in.Label)
}
if id == "" {
badRequest(w, "give the camera a name, such as Entrance")
return
}
if in.Label == nil || trim(*in.Label) == "" {
in.Label = &id
}
if msg, ok := cameraProblem(in); !ok {
badRequest(w, msg)
return
}
s.saveCamera(w, r, p.ClientID, siteID, id, in, http.StatusCreated)
}
func (s *Server) handleUpdateCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
existing, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
var in CameraInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
// The camera id is what visits are recorded against. Renaming it would
// orphan every visit already attributed to the old name, so the label is
// the thing an operator may change.
in.CameraID = nil
s.saveCamera(w, r, p.ClientID, existing.SiteID, existing.CameraID, in, http.StatusOK)
}
func (s *Server) saveCamera(w http.ResponseWriter, r *http.Request,
clientID, siteID, cameraID string, in CameraInput, code int) {
p := PrincipalFrom(r.Context())
cam, err := s.Store.SaveCamera(r.Context(), clientID, siteID, cameraID, in)
if err != nil {
if errors.Is(err, ErrNoSecrets) {
// A camera saved with its password silently dropped is a camera
// that will not connect, and the operator could not tell that from
// a wrong password.
writeErr(w, http.StatusServiceUnavailable, "no_secret_key", err.Error())
return
}
if strings.Contains(err.Error(), "no rows") {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
s.serverError(w, "save camera", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: clientID, ActorID: p.UserID, ActorKind: "user",
Action: "camera.save", Entity: "camera", EntityID: cam.ID,
Detail: map[string]any{"camera_id": cam.CameraID, "site_id": siteID},
})
// Through the slice, not around it. `attachSnapshots([]Camera{cam})` would
// decorate a COPY and then serialise the untouched original, so a created
// camera came back with an empty snapshot object and no reason - the one
// field whose whole job is to say why there is no picture.
cams := []Camera{cam}
s.attachSnapshots(cams)
writeJSON(w, code, cams[0])
}
func (s *Server) handleDeleteCamera(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change camera settings.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
cam, err := s.Store.DeleteCamera(r.Context(), p.ClientID, id)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "camera.delete", Entity: "camera", EntityID: cam.ID,
Detail: map[string]any{"camera_id": cam.CameraID},
})
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------------------ agent
// handleAgentCameras is the shop PC asking what it should be running.
//
// Authenticated by the agent's own token, and scoped to that agent's site by
// the credential rather than by anything in the request - a site id a caller
// could set would hand one shop another shop's camera passwords.
func (s *Server) handleAgentCameras(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
cams, err := s.Store.AgentCameras(r.Context(), ap.SiteID)
if err != nil {
s.serverError(w, "agent cameras", err)
return
}
if cams == nil {
cams = []AgentCamera{}
}
writeJSON(w, http.StatusOK, map[string]any{"cameras": cams})
}
// handleAgentCameraReport records what the shop PC observes and adopts any
// camera it is running that head office does not know about.
func (s *Server) handleAgentCameraReport(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
var rep AgentCameraReport
if err := decode(w, r, &rep); err != nil {
badRequest(w, err.Error())
return
}
for i := range rep.Adopt {
// Slugged here as well as on the operator path. An agent is trusted to
// report its own site, not to choose an identifier that could collide
// with a topic segment or a path.
rep.Adopt[i].CameraID = cameraSlug(rep.Adopt[i].CameraID)
}
// ClientID, not Client. AgentPrincipal carries both the tenant's uuid and
// its human slug, and the slug is the one that reads correctly in a log
// line - which is exactly why it gets used by mistake in a query that wants
// the uuid.
if err := s.Store.ApplyAgentReport(r.Context(), ap.ClientID, ap.SiteID, rep); err != nil {
s.serverError(w, "agent camera report", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- helpers
// cameraSlug normalises the id the engine will know this camera by.
//
// It ends up in an object key, a URL path and a topic segment, so it is
// restricted to characters that cannot change what any of those mean.
func cameraSlug(s string) string {
var b strings.Builder
lastDash := true
for _, r := range strings.ToLower(trim(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case !lastDash:
b.WriteByte('-')
lastDash = true
}
}
return clip(strings.Trim(b.String(), "-"), 64)
}
// cameraProblem rejects a camera that cannot possibly connect, with the
// sentence an operator needs rather than a validation code.
func cameraProblem(in CameraInput) (string, bool) {
if in.Host == nil || trim(*in.Host) == "" {
return "the camera needs an address on the shop's network, such as 192.168.0.138", false
}
if in.Port != nil && (*in.Port < 1 || *in.Port > 65535) {
return "the port must be between 1 and 65535 - RTSP cameras are usually 554", false
}
if in.MaxWidth != nil && *in.MaxWidth < 320 {
return "frames narrower than 320 pixels are too small to recognise a face in", false
}
return "", true
}

View File

@@ -0,0 +1,320 @@
package api
import (
"fmt"
"net/http"
"time"
)
// Proving a camera works, and proving a site works.
//
// The engine already answers both questions and already phrases its answers for
// whoever is standing next to the camera. Nothing here re-words them; this is
// the channel that was missing, plus the one judgement head office can make on
// its own - whether the shop PC is even talking to us.
const (
// A placement check asks somebody to walk through the frame and out of it.
// Under ~15 s and an installer has no time to do that; over ~60 s and they
// have wandered off.
minCheckSeconds = 15
defaultCheckSeconds = 25
maxCheckSeconds = 60
// A shop PC that claimed a check and never reported is assumed to have been
// restarted mid-check. Long enough that a slow placement run is not
// stolen from itself.
checkStaleAfter = 5 * time.Minute
)
func (s *Server) handleRequestCheck(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot run camera checks.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
var req CheckRequest
if err := decode(w, r, &req); err != nil {
badRequest(w, err.Error())
return
}
switch req.Kind {
case "connection", "placement":
case "":
req.Kind = "connection"
default:
badRequest(w, `kind must be "connection" or "placement"`)
return
}
if req.Seconds == 0 {
req.Seconds = defaultCheckSeconds
}
if req.Seconds < minCheckSeconds || req.Seconds > maxCheckSeconds {
badRequest(w, fmt.Sprintf(
"a placement check runs for between %d and %d seconds - long enough "+
"to walk through the frame, short enough that nobody wanders off",
minCheckSeconds, maxCheckSeconds))
return
}
if err := s.Store.RequestCheck(r.Context(), p.ClientID, id, req.Kind, req.Seconds); err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
cam, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
if err != nil {
s.serverError(w, "camera after check request", err)
return
}
cams := []Camera{cam}
s.attachSnapshots(cams)
// 202: the shop PC has not run it yet, and saying 200 would invite a client
// to read the (empty) result as the answer.
writeJSON(w, http.StatusAccepted, cams[0])
}
// ------------------------------------------------------------------ agent
func (s *Server) handleAgentChecks(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
// Release anything a previous run claimed and abandoned before handing out
// work, so a PC restarted mid-check picks its own job back up rather than
// leaving the camera showing "checking..." for ever.
if err := s.Store.ReleaseStaleChecks(r.Context(), checkStaleAfter); err != nil {
s.logf("ERROR releasing stale checks: %v", err)
}
jobs, err := s.Store.ClaimChecks(r.Context(), ap.SiteID)
if err != nil {
s.serverError(w, "claim checks", err)
return
}
if jobs == nil {
jobs = []AgentCheckJob{}
}
writeJSON(w, http.StatusOK, map[string]any{"checks": jobs})
}
func (s *Server) handleAgentCheckResult(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
var res AgentCheckResult
if err := decode(w, r, &res); err != nil {
badRequest(w, err.Error())
return
}
res.CameraID = cameraSlug(res.CameraID)
if res.CameraID == "" {
badRequest(w, "camera_id is required")
return
}
if err := s.Store.RecordCheckResult(r.Context(), ap.SiteID, res); err != nil {
s.serverError(w, "record check result", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------- the site smoke test
// handleSiteCheck answers "is this shop working", end to end.
//
// Assembled entirely from what head office already knows, so it costs no round
// trip to the shop and works when the PC is off - which is itself one of the
// answers, and the one a footfall report cannot give.
//
// Ordered, and it stops judging once something fails: asking whether cameras
// see faces on a PC that is switched off produces an answer that means nothing,
// and printing it next to a real failure buries the real failure.
func (s *Server) handleSiteCheck(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
siteID := r.PathValue("site")
if !looksLikeUUID(siteID) {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
if err != nil {
s.serverError(w, "site check", err)
return
}
var site *SiteHealth
for i := range sites {
if sites[i].SiteID == siteID {
site = &sites[i]
break
}
}
if site == nil {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
if err != nil {
s.serverError(w, "site check cameras", err)
return
}
out := SiteCheck{SiteID: site.SiteID, Site: site.Name,
Steps: BuildSiteSteps(site, cams, s.now())}
out.OK = true
for _, st := range out.Steps {
if st.Status != "pass" {
out.OK = false
break
}
}
writeJSON(w, http.StatusOK, out)
}
// buildSiteSteps is the whole judgement, in one place and with no I/O, so it
// can be tested against every combination without a database.
func BuildSiteSteps(site *SiteHealth, cams []Camera, now time.Time) []CheckStep {
steps := make([]CheckStep, 0, 5)
// 1. Is the shop PC talking to us at all? Everything below is unknowable
// until this passes, so a failure here stops the rest being judged.
pc := CheckStep{Name: "The shop's PC is online"}
switch {
case site.LastHeartbeatAt == "":
pc.Status, pc.Detail = "fail", "This PC has never reported in."
pc.Advice = "Install Behavision on the shop's PC and claim it with the enrolment code for this shop."
case !site.Online:
pc.Status = "fail"
pc.Detail = "Last heard from " + humanAgo(site.LastHeartbeatAt, now) + "."
pc.Advice = "Check the PC is switched on, and that it has internet."
default:
pc.Status, pc.Detail = "pass", "Reported in "+humanAgo(site.LastHeartbeatAt, now)+"."
}
steps = append(steps, pc)
if pc.Status == "fail" {
return append(steps, unknownStep("Cameras are connected"),
unknownStep("Cameras can recognise faces"),
unknownStep("Visits are reaching head office"))
}
// 2. Recognition running, and WHICH model - on a memory-starved box the
// large model loses the fallback chain and the process stays up anyway.
rec := CheckStep{Name: "Recognition is running"}
if site.RecognitionModel == "" {
rec.Status = "warn"
rec.Detail = "The PC is online but has not said which recognition model it loaded."
rec.Advice = "Open Behavision on the shop's PC and check it is started."
} else {
rec.Status, rec.Detail = "pass", "Using "+site.RecognitionModel+"."
}
steps = append(steps, rec)
// 3. Cameras connected.
cam := CheckStep{Name: "Cameras are connected"}
switch {
case len(cams) == 0:
cam.Status, cam.Detail = "fail", "No cameras have been set up for this shop."
cam.Advice = "Add a camera, then run this check again."
default:
var up, down, untested int
for _, c := range cams {
switch {
case c.Connected == nil:
untested++
case *c.Connected:
up++
default:
down++
}
}
switch {
case down > 0:
cam.Status = "fail"
cam.Detail = fmt.Sprintf("%d of %d cameras are not connecting.", down, len(cams))
cam.Advice = "Open the camera and use Check connection to see why."
case untested > 0 && up == 0:
cam.Status = "warn"
cam.Detail = fmt.Sprintf("%d camera(s) set up, none tried yet by the shop's PC.", untested)
cam.Advice = "Wait a couple of minutes, or use Check connection on a camera."
default:
cam.Status = "pass"
cam.Detail = fmt.Sprintf("%d of %d connected.", up, len(cams))
}
}
steps = append(steps, cam)
// 4. The question the whole product turns on, and the one Office1 failed
// silently for weeks: not "is a camera plugged in" but "does a person
// walking past produce a view good enough to recognise".
faces := CheckStep{Name: "Cameras can recognise faces"}
switch {
case site.FractionBelowGate > 0.5:
faces.Status = "fail"
faces.Detail = fmt.Sprintf(
"%.0f%% of the faces seen were too poor to recognise.",
site.FractionBelowGate*100)
faces.Advice = "The camera needs moving: face the way people walk in, at about head height."
case site.FractionBelowGate > 0.2:
faces.Status = "warn"
faces.Detail = fmt.Sprintf("%.0f%% of faces seen were too poor to recognise.",
site.FractionBelowGate*100)
faces.Advice = "Some visitors are being missed. Run a walk-past check on each camera."
case site.LastEventAt == "":
// Not a failure. A shop that has just been set up has seen nobody yet,
// and calling that broken sends an installer looking for a fault that
// does not exist.
faces.Status = "unknown"
faces.Detail = "Nobody has walked past yet."
faces.Advice = "Run a walk-past check on a camera to prove it before the shop opens."
default:
faces.Status = "pass"
faces.Detail = "Faces seen are good enough to recognise."
}
steps = append(steps, faces)
// 5. Do visits actually arrive? A shop can be recognising people perfectly
// and reporting none of it.
send := CheckStep{Name: "Visits are reaching head office"}
switch {
case site.Dropped > 0:
send.Status = "fail"
send.Detail = fmt.Sprintf("%d visits were lost - this PC was offline too long.", site.Dropped)
send.Advice = "Check this shop's internet. Those visits cannot be recovered."
case site.Queued > 20:
send.Status = "warn"
send.Detail = fmt.Sprintf("%d visits are waiting to be sent.", site.Queued)
send.Advice = "The PC is recording but not sending. Check its internet connection."
case site.LastEventAt == "":
send.Status = "unknown"
send.Detail = "No visits recorded yet."
default:
send.Status = "pass"
send.Detail = "Last visit received " + humanAgo(site.LastEventAt, now) + "."
}
steps = append(steps, send)
return steps
}
func unknownStep(name string) CheckStep {
return CheckStep{Name: name, Status: "unknown",
Detail: "Cannot be checked until the shop's PC is online."}
}
// humanAgo phrases a timestamp the way somebody reading a status page would say
// it. Deliberately vague at the top end: "3 days ago" is as actionable as
// "3 days and 4 hours ago" and far easier to scan.
func humanAgo(iso string, now time.Time) string {
t, err := time.Parse(time.RFC3339, iso)
if err != nil {
return "at an unknown time"
}
d := now.Sub(t)
switch {
case d < 90*time.Second:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
}

View File

@@ -0,0 +1,67 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/jackc/pgx/v5"
)
// Issuing the code that claims a shop PC.
//
// This existed only as a provisioning command, which made every replacement PC
// a support ticket and an SSH session - and a shop PC is exactly the kind of
// machine that gets replaced, reimaged and swapped between branches. The
// command remains the bootstrap, because a brand new customer has nobody to
// sign in as yet; this is for every time after that.
//
// Manager and above, never staff: the code is redeemed for the site's broker
// password, so it is a credential in its own right, not a convenience.
func (s *Server) handleIssueEnrolmentCode(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot set up shop computers. Ask a manager or the owner.")
return
}
site := r.PathValue("site")
if !looksLikeUUID(site) {
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
return
}
var in NewEnrolmentCodeInput
if err := decodeOptional(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
// A week by default, and a month at the very most. The code is read aloud,
// photographed and pasted into chat on its way to a shop; a long-lived one
// is a broker credential lying about in a WhatsApp thread.
days := in.Days
if days <= 0 {
days = 7
}
if days > 30 {
days = 30
}
out, err := s.Store.IssueEnrolmentCode(r.Context(), p.ClientID, site,
p.UserID, clip(trim(in.Label), 120), time.Duration(days)*24*time.Hour)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
return
}
s.serverError(w, "issue enrolment code", err)
return
}
// A code hands out a site's broker password, so who minted one and when is
// worth a row - the same reason every read of a face image writes one.
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "enrolment_code.issued", Entity: "site", EntityID: site,
Detail: map[string]any{"label": out.Label, "expires_at": out.ExpiresAt},
})
// 201: a credential was created. The body is the only time it is readable.
writeJSON(w, http.StatusCreated, out)
}

View File

@@ -0,0 +1,180 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
// agentAuthed authenticates a store PC by its own API token.
//
// Deliberately a separate middleware from authed(): an agent has no user, no
// role and no session, and folding it into the person path would mean one set
// of permission checks answering two very different questions about who is
// asking.
func (s *Server) agentAuthed(next func(http.ResponseWriter, *http.Request, AgentPrincipal)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := auth.BearerToken(r)
if tok == "" {
unauthorized(w, "this endpoint is for a Behavision agent")
return
}
ap, err := s.Store.AgentByToken(r.Context(), auth.HashToken(tok))
if err != nil {
unauthorized(w, "this agent is not enrolled")
return
}
next(w, r, ap)
}
}
// handleUploadURL hands a store PC permission to write exactly one object.
//
// The shop PC never holds bucket credentials. That is not belt-and-braces: the
// bucket is shared with another application and is world-readable at the bucket
// level, so a full key on a machine that sits on a shop counter would expose
// far more than this product's own data. A stolen PC gives up, at most, a few
// minutes of write access to one key it was already going to write.
func (s *Server) handleUploadURL(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
if s.Blob == nil {
// Not an error the agent should retry against: images are simply off
// for this deployment, and it should carry on sending visits without
// one rather than queueing failures.
writeErr(w, http.StatusNotImplemented, "images_disabled",
"This server is not configured to store images.")
return
}
// The KEY is built here, from the credential the request authenticated
// with. Accepting a caller-supplied key would let one site overwrite
// another's images, which is the whole reason this endpoint exists instead
// of a shared bucket password.
key := s.Blob.Key(ap.Client, ap.Site, newObjectID(), s.now())
url, hdr, err := s.Blob.PresignPut(key, uploadTTL)
if err != nil {
s.serverError(w, "presign upload", err)
return
}
// Lower-cased deliberately. SigV4 signs header names in lower case, and
// this map is a wire contract that a non-Go client will copy literally -
// http.Header's canonical "X-Amz-Acl" would send them looking for a
// mismatch that only exists in Go's map keys.
headers := map[string]string{}
for k := range hdr {
headers[strings.ToLower(k)] = hdr.Get(k)
}
writeJSON(w, http.StatusOK, UploadTarget{
Key: key, URL: url, Headers: headers,
ExpiresIn: int(uploadTTL.Seconds()),
})
}
const (
// Long enough for a slow shop connection to finish a 30 KB JPEG, short
// enough that a URL captured in a log is worthless by the time anyone
// reads it.
uploadTTL = 10 * time.Minute
// Read URLs end up in browser history, screenshots and support tickets.
viewTTL = 15 * time.Minute
)
// handleVisitorImage returns a short-lived link to a customer's most recent
// face image.
//
// A link that expires, never a stored URL: "delete my data" has to mean the
// link stops working, not that we stop publishing it.
func (s *Server) handleVisitorImage(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
if s.Blob == nil {
writeErr(w, http.StatusNotFound, "images_disabled",
"This server does not store images.")
return
}
key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id)
if err != nil || key == "" {
writeErr(w, http.StatusNotFound, "no_image",
"There is no photo for this customer.")
return
}
url, err := s.Blob.PresignGet(key, viewTTL)
if err != nil {
s.serverError(w, "presign read", err)
return
}
// Every read of a face image is worth a row. If a client asks "who looked
// at my customers", an audit trail is the only answer that is not a guess.
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "image.view", Entity: "visitor", EntityID: id,
})
writeJSON(w, http.StatusOK, map[string]any{
"url": url, "expires_in": int(viewTTL.Seconds()),
})
}
// handleForgetVisitor is the erasure path.
//
// It destroys the biometric template and the face image outright, and keeps
// only what is genuinely aggregate: the visit rows stay so a shop's past
// footfall does not silently change, but they no longer point at a person, a
// name or a picture.
//
// The images go FIRST. If the database transaction commits and the object
// delete then fails, the keys are gone and nothing knows which files to remove
// - the image outlives the erasure request with no record that it should not.
func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot delete customer records.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
keys, err := s.Store.VisitorImageKeys(r.Context(), p.ClientID, id)
if err != nil {
s.serverError(w, "list images for erasure", err)
return
}
if s.Blob != nil {
for _, key := range keys {
if err := s.Blob.Delete(r.Context(), key); err != nil {
// Refuse the whole request. Reporting an erasure as done while
// a face image is still in the bucket is the one outcome this
// endpoint must never produce.
s.logf("ERROR erasure %s: cannot delete %s: %v", id, key, err)
writeErr(w, http.StatusBadGateway, "storage_error",
"The photo could not be deleted, so nothing was erased. "+
"Please try again.")
return
}
}
}
if err := s.Store.ForgetVisitor(r.Context(), p.ClientID, id); err != nil {
if strings.Contains(err.Error(), "no such visitor") {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
s.serverError(w, "forget visitor", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "visitor.forget", Entity: "visitor", EntityID: id,
Detail: map[string]any{"images_deleted": len(keys)},
})
s.logf("erasure: visitor %s for client %s, %d image(s) deleted",
id, p.ClientID, len(keys))
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,172 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
func (s *Server) handleVisitors(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
q := trim(r.URL.Query().Get("q"))
limit := queryInt(r, "limit", 50, 500)
out, err := s.Store.SearchVisitors(r.Context(), p.ClientID, q, limit)
if err != nil {
s.serverError(w, "search visitors", err)
return
}
if out == nil {
out = []Customer{}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleVisitorHistory(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
id := r.PathValue("id")
if !looksLikeUUID(id) {
// 404, not 400: to the caller a malformed id and an id that does not
// exist are the same thing - the customer is not there.
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
rows, err := s.Store.VisitorHistory(r.Context(), p.ClientID, id,
queryInt(r, "limit", 100, 1000))
if err != nil {
s.serverError(w, "visitor history", err)
return
}
if rows == nil {
rows = []VisitRow{}
}
writeJSON(w, http.StatusOK, rows)
}
// handleSaveProfile attaches a name, a phone number and a consent record to a
// face the system already knows.
//
// PUT, and idempotent on visitor_id: staff fill this in on a shop floor with
// bad wifi, and a resubmit must correct the record rather than create a second
// one for the same person.
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanWriteProfiles() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot edit customer details.")
return
}
var body Profile
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
// The path wins over the body. Trusting the body would let a client PUT to
// one customer's URL and write to another's record.
body.VisitorID = r.PathValue("id")
if !looksLikeUUID(body.VisitorID) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
body.FullName = clip(trim(body.FullName), 200)
body.Phone = clip(trim(body.Phone), 40)
body.Email = auth.NormalizeEmail(body.Email)
body.Gender = clip(trim(body.Gender), 32)
body.Notes = clip(trim(body.Notes), 2000)
if body.DateOfBirth != "" {
if _, err := time.Parse("2006-01-02", body.DateOfBirth); err != nil {
badRequest(w, "date of birth must look like 2001-04-23")
return
}
}
if body.FullName == "" && body.Phone == "" && body.Email == "" {
badRequest(w, "give at least a name, a phone number or an email")
return
}
if err := s.Store.SaveProfile(r.Context(), p.ClientID, body, p.UserID); err != nil {
if strings.Contains(err.Error(), "no such visitor") {
// 404, not 403: within one client this is a typo, and the tenant
// scoping in the query already made a cross-tenant id unfindable.
writeErr(w, http.StatusNotFound, "not_found",
"That customer no longer exists.")
return
}
s.serverError(w, "save profile", err)
return
}
// Audited because it links a real name to a biometric template. If a client
// ever asks who put a name to a face, a guess is not an answer.
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "profile.save", Entity: "visitor", EntityID: body.VisitorID,
Detail: map[string]any{"consent": body.Consent},
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanWriteProfiles() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot record purchases.")
return
}
var body PurchaseInput
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
body.VisitorID = trim(body.VisitorID)
if body.VisitorID == "" {
badRequest(w, "visitor_id is required")
return
}
if !looksLikeUUID(body.VisitorID) {
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
return
}
if body.Amount < 0 {
// A refund is a different record with a different meaning, not a
// negative sale. Allowing it here would quietly deflate the revenue
// figure the conversion report is judged by.
badRequest(w, "amount cannot be negative")
return
}
if body.Currency == "" {
body.Currency = "INR"
}
if len(body.Currency) != 3 {
badRequest(w, "currency must be a 3-letter code")
return
}
body.Currency = strings.ToUpper(body.Currency)
if body.Source == "" {
body.Source = "manual"
}
body.Notes = clip(trim(body.Notes), 2000)
if err := s.Store.RecordPurchase(r.Context(), p.ClientID, body, p.UserID); err != nil {
switch {
case strings.Contains(err.Error(), "no such visitor"):
writeErr(w, http.StatusNotFound, "not_found",
"That customer no longer exists.")
case strings.Contains(err.Error(), "no site"):
// This is actionable, so it says what to do rather than failing
// with a foreign key error nobody can read.
badRequest(w, "this customer has never been seen at a store, "+
"so there is no site to book the sale against - pass site_id")
default:
s.serverError(w, "record purchase", err)
}
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "purchase.record", Entity: "visitor", EntityID: body.VisitorID,
Detail: map[string]any{"amount": body.Amount, "currency": body.Currency},
})
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,139 @@
package api
import (
"fmt"
"net/http"
"time"
)
// Buckets a report may be cut into. A whitelist rather than passing the string
// through: date_trunc takes a text argument, so an unchecked value is either a
// database error surfaced to a shop floor or, in a query built by
// concatenation, something much worse.
var buckets = map[string]bool{
"hour": true, "day": true, "week": true, "month": true,
}
// maxWindow bounds a report at two years. Not for safety - for honesty: an
// open-ended range on a 2 vCPU box times out at the proxy and the user sees a
// blank screen with no explanation.
const maxWindow = 2 * 366 * 24 * time.Hour
func (s *Server) reportQuery(r *http.Request) (ReportQuery, error) {
p := PrincipalFrom(r.Context())
q := r.URL.Query()
// The tenant comes from the session. A client_id parameter would be a
// cross-tenant read waiting for somebody to try it.
out := ReportQuery{ClientID: p.ClientID, SiteID: trim(q.Get("site"))}
now := s.now()
from, err := parseDay(q.Get("from"), now.AddDate(0, 0, -29))
if err != nil {
return out, fmt.Errorf("`from` is not a date: %w", err)
}
to, err := parseDay(q.Get("to"), now)
if err != nil {
return out, fmt.Errorf("`to` is not a date: %w", err)
}
// `to` is inclusive to the user ("1st to the 7th" includes the 7th) and
// exclusive in SQL. Doing that conversion in one place is the difference
// between a report that quietly misses its own last day and one that does
// not.
if len(trim(q.Get("to"))) == 10 {
to = to.AddDate(0, 0, 1)
}
if !to.After(from) {
return out, fmt.Errorf("`to` must be after `from`")
}
if to.Sub(from) > maxWindow {
return out, fmt.Errorf("that range is longer than two years - " +
"please narrow it")
}
out.From, out.To = from, to
out.Bucket = trim(q.Get("bucket"))
if out.Bucket == "" {
out.Bucket = "day"
}
if !buckets[out.Bucket] {
return out, fmt.Errorf("bucket must be hour, day, week or month")
}
out.Timezone = trim(q.Get("tz"))
if out.Timezone == "" {
out.Timezone = "UTC"
}
// Validated here, where a bad name is a 400 the user can fix, rather than
// in Postgres where it is a 500.
if _, err := time.LoadLocation(out.Timezone); err != nil {
return out, fmt.Errorf("unknown timezone %q", out.Timezone)
}
return out, nil
}
// parseDay accepts a plain date or a full RFC3339 timestamp. Shop staff type
// dates; the desktop app sends timestamps.
func parseDay(s string, def time.Time) (time.Time, error) {
s = trim(s)
if s == "" {
return def.UTC(), nil
}
if len(s) == 10 {
return time.Parse("2006-01-02", s)
}
t, err := time.Parse(time.RFC3339, s)
return t.UTC(), err
}
func (s *Server) handleFootfall(w http.ResponseWriter, r *http.Request) {
q, err := s.reportQuery(r)
if err != nil {
badRequest(w, err.Error())
return
}
points, totals, err := s.Store.Footfall(r.Context(), q)
if err != nil {
s.serverError(w, "footfall", err)
return
}
writeJSON(w, http.StatusOK, FootfallReport{
From: q.From.Format(time.RFC3339),
To: q.To.Format(time.RFC3339),
Bucket: q.Bucket,
TZ: q.Timezone,
Points: points,
Total: totals.UniqueVisitors,
Visits: totals.Visits,
FractionBelowGate: totals.FractionBelowGate,
WorstSite: totals.WorstSite,
})
}
func (s *Server) handleConversion(w http.ResponseWriter, r *http.Request) {
q, err := s.reportQuery(r)
if err != nil {
badRequest(w, err.Error())
return
}
rep, err := s.Store.Conversion(r.Context(), q)
if err != nil {
s.serverError(w, "conversion", err)
return
}
writeJSON(w, http.StatusOK, rep)
}
func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
if err != nil {
s.serverError(w, "site health", err)
return
}
if sites == nil {
// A JSON null would make every caller handle two empty cases.
sites = []SiteHealth{}
}
writeJSON(w, http.StatusOK, sites)
}

View File

@@ -0,0 +1,86 @@
package api
import "sync"
// Hub wakes live listeners when a tenant's data changes.
//
// It is a DOORBELL, not a delivery service: a notification carries a client id
// and nothing else, and every listener answers it by running the same keyset
// query a polling client would. That is the whole design decision, and it buys
// three things that a hub carrying the rows does not:
//
// - One query path. The stream and the poll cannot disagree about what an
// arrival looks like, because there is only one piece of code that reads
// one.
// - No lost events. A subscriber that is mid-reconnect, or slow, or was not
// listening yet, misses a doorbell and loses nothing - its next query
// starts from its own cursor and picks up everything in between. A hub
// that pushed rows would have to buffer per subscriber and decide what to
// drop, which is a queue, and we already have a durable one.
// - Degrades to polling. If a second server instance is ever added, its
// ingest rings a doorbell this process never hears. The stream keeps a
// slow fallback tick for exactly that, so the failure mode is latency,
// not silence.
//
// Notify is called from the MQTT consumer's goroutine and must never block it:
// a slow subscriber must not be able to stall ingest for the whole estate.
type Hub struct {
mu sync.Mutex
next int
subs map[int]chan string
}
func NewHub() *Hub { return &Hub{subs: map[int]chan string{}} }
// Subscribe returns a channel of client ids and a function to release it.
// Callers MUST call the returned func, or the subscriber leaks for the life of
// the process - one goroutine and one buffered channel per abandoned HTTP
// connection, on an endpoint mobile clients reconnect to all day.
func (h *Hub) Subscribe() (<-chan string, func()) {
// Buffered by one. A doorbell is idempotent - two rings while the listener
// is busy mean the same thing as one, because it re-queries from its
// cursor either way - so a single slot is enough and a full channel is a
// normal state, not backpressure to worry about.
ch := make(chan string, 1)
h.mu.Lock()
id := h.next
h.next++
h.subs[id] = ch
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
if c, ok := h.subs[id]; ok {
delete(h.subs, id)
close(c)
}
h.mu.Unlock()
}
}
// Notify rings every subscriber. Never blocks: a subscriber whose slot is
// already full is skipped, because it has a pending wake-up that will make it
// re-query anyway.
func (h *Hub) Notify(clientID string) {
if h == nil || clientID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
for _, ch := range h.subs {
select {
case ch <- clientID:
default:
}
}
}
// Subscribers is the count, for tests and for /healthz.
func (h *Hub) Subscribers() int {
if h == nil {
return 0
}
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}

View File

@@ -0,0 +1,240 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/loyaly/behavision-server/internal/auth"
)
// enrol runs a real enrolment and returns the agent's own API token.
func enrol(t *testing.T, s *Server, fs *fakeStore) string {
t.Helper()
code := "ABCDEF-123456"
fs.enrolment[hashHex(code)] = Enrolment{
ClientID: "client-acme", AgentID: "agent-1", SiteID: "site-1",
SiteName: "Chennai", SiteSlug: "store1",
MQTTUser: "acme.store1", MQTTPass: "broker-secret",
}
rec := do(t, s, "POST", "/api/agent/enrol", "", map[string]string{"site_token": code})
if rec.Code != http.StatusOK {
t.Fatalf("enrol failed: %d %s", rec.Code, rec.Body.String())
}
var got map[string]any
json.Unmarshal(rec.Body.Bytes(), &got) //nolint:errcheck
tok, _ := got["agent_token"].(string)
if tok == "" {
t.Fatal("enrolment did not return an agent token")
}
return tok
}
func TestEnrolmentIssuesAnAgentTokenSeparateFromTheBrokerPassword(t *testing.T) {
s, fs := newServer(t)
tok := enrol(t, s, fs)
// Two secrets for two different questions: the broker password says this
// site may publish events, the agent token says it may ask the API for
// something. One secret for both means rotating either breaks the other.
if tok == "broker-secret" {
t.Fatal("the agent token is the broker password")
}
if _, err := fs.AgentByToken(t.Context(), auth.HashToken(tok)); err != nil {
t.Fatalf("the issued token does not authenticate: %v", err)
}
}
func TestUploadURLRequiresAnEnrolledAgent(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
if rec := do(t, s, "POST", "/api/agent/upload-url", "", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated upload-url returned %d", rec.Code)
}
if rec := do(t, s, "POST", "/api/agent/upload-url", "not-a-token", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("a bogus agent token was accepted: %d", rec.Code)
}
// A staff session is not an agent. The two are authenticated differently
// and must not be interchangeable.
sess := login(t, s, "manager@acme.com", "correct horse battery")
if rec := do(t, s, "POST", "/api/agent/upload-url", sess.Token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("a user session was accepted as an agent: %d", rec.Code)
}
}
// The server picks the key from the credential the request authenticated with.
// A caller-supplied key would let one site overwrite another's images, which is
// the entire reason uploads are presigned instead of shipping a bucket password.
func TestTheServerChoosesTheKeyNotTheAgent(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{}
s.Blob = blob
tok := enrol(t, s, fs)
rec := do(t, s, "POST", "/api/agent/upload-url", tok,
map[string]string{"content_type": "image/jpeg"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var target UploadTarget
json.Unmarshal(rec.Body.Bytes(), &target) //nolint:errcheck
if !strings.HasPrefix(target.Key, "behavision/acme/store1/") {
t.Fatalf("key is not namespaced to the authenticated site: %q", target.Key)
}
// The ACL must be handed back for the agent to send, because it is inside
// the signature: the shop PC cannot decide to publish the image instead.
if target.Headers["x-amz-acl"] != "private" {
t.Fatalf("upload does not force a private ACL: %v", target.Headers)
}
if target.URL == "" || target.ExpiresIn <= 0 {
t.Fatalf("incomplete upload target: %+v", target)
}
// Two requests must not collide on one object.
rec2 := do(t, s, "POST", "/api/agent/upload-url", tok, nil)
var second UploadTarget
json.Unmarshal(rec2.Body.Bytes(), &second) //nolint:errcheck
if second.Key == target.Key {
t.Fatal("two uploads were given the same key")
}
}
func TestUploadIsRefusedCleanlyWhenImagesAreOff(t *testing.T) {
s, fs := newServer(t)
s.Blob = nil // the default: this product stores no images unless told to
tok := enrol(t, s, fs)
rec := do(t, s, "POST", "/api/agent/upload-url", tok, nil)
// 501, not 500: the agent should carry on sending visits without a photo
// rather than treating this as a failure to retry.
if rec.Code != http.StatusNotImplemented {
t.Fatalf("got %d, want 501", rec.Code)
}
}
func TestVisitorImageIsAShortLivedLinkAndIsAudited(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{}
s.Blob = blob
seedUser(fs)
fs.imageKeys[visitorAID] = "behavision/acme/store1/2026/08/31/abc.jpg"
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", visitorA+"/image", sess.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var body map[string]any
json.Unmarshal(rec.Body.Bytes(), &body) //nolint:errcheck
url, _ := body["url"].(string)
if !strings.Contains(url, "X-Amz-Signature") {
t.Fatalf("not a presigned link: %q", url)
}
if body["expires_in"] == nil {
t.Fatal("the caller is not told the link expires")
}
// Every read of a face image is worth a row: "who looked at my customers"
// needs an answer that is not a guess.
var audited bool
for _, a := range fs.audits {
if a.Action == "image.view" && a.EntityID == visitorAID {
audited = true
}
}
if !audited {
t.Fatalf("viewing a face image was not audited: %+v", fs.audits)
}
}
func TestAnotherTenantsImageIsNotFound(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
// The store scopes by client, so a foreign id simply has no key.
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "GET", "/api/visitors/"+visitorB+"/image", sess.Token, nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
}
// -- erasure ----------------------------------------------------------------
func TestErasureDeletesTheImageBeforeTheDatabaseRow(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{}
s.Blob = blob
seedUser(fs)
key := "behavision/acme/store1/2026/08/31/abc.jpg"
fs.imageKeys[visitorAID] = key
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "DELETE", visitorA, sess.Token, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(blob.deleted) != 1 || blob.deleted[0] != key {
t.Fatalf("the face image was not deleted from storage: %v", blob.deleted)
}
if len(fs.forgotten) != 1 || fs.forgotten[0] != visitorAID {
t.Fatalf("the database record was not erased: %v", fs.forgotten)
}
}
// If the object delete fails and the row is erased anyway, the keys are gone
// and nothing knows which files to remove - the image outlives the request with
// no record that it should not. Reporting success there is the one outcome this
// endpoint must never produce.
func TestAFailedImageDeleteAbortsTheWholeErasure(t *testing.T) {
s, fs := newServer(t)
blob := &fakeBlob{failNext: errors.New("bucket unreachable")}
s.Blob = blob
seedUser(fs)
fs.imageKeys[visitorAID] = "behavision/acme/store1/2026/08/31/abc.jpg"
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "DELETE", visitorA, sess.Token, nil)
if rec.Code != http.StatusBadGateway {
t.Fatalf("got %d, want 502", rec.Code)
}
if len(fs.forgotten) != 0 {
t.Fatal("the database row was erased while the photo survived")
}
// And the operator is told to retry rather than believing it is done.
if !strings.Contains(strings.ToLower(rec.Body.String()), "try again") {
t.Fatalf("unhelpful message: %s", rec.Body.String())
}
}
func TestOnlyManagersAndAboveCanErase(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
fs.addUser("shopfloor@acme.com", "correct horse battery", UserRecord{
ID: "u7", ClientID: "client-acme", Role: "staff", Active: true,
})
sess := login(t, s, "shopfloor@acme.com", "correct horse battery")
// Staff fill in the customer form; destroying a record is a different
// decision with a different blast radius.
if rec := do(t, s, "DELETE", visitorA, sess.Token, nil); rec.Code != http.StatusForbidden {
t.Fatalf("staff could erase a customer: %d", rec.Code)
}
}
func TestErasureWithNoImageStillErasesTheRecord(t *testing.T) {
s, fs := newServer(t)
s.Blob = &fakeBlob{}
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
// Most visitors have no photo. Erasure must not depend on there being one.
if rec := do(t, s, "DELETE", visitorA, sess.Token, nil); rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.forgotten) != 1 {
t.Fatalf("record not erased: %v", fs.forgotten)
}
}

View File

@@ -0,0 +1,25 @@
package api
import (
"os"
"testing"
"github.com/loyaly/behavision-server/internal/auth"
)
// The whole package runs at bcrypt's minimum cost.
//
// Almost every test here signs in, and at the production cost of 12 that is
// ~500 ms of hashing per test for a hash and a verify. Under the race detector
// that pushed this package past `go test`'s ten-minute default - a CI failure
// with no failing assertion in it, which is the worst kind to debug.
//
// What this does NOT weaken: the tests that care about hashing care about
// whether two paths take the SAME time as each other, not how long either
// takes. Lowering both sides equally leaves that intact.
func TestMain(m *testing.M) {
restore := auth.UseTestCost()
code := m.Run()
restore()
os.Exit(code)
}

View File

@@ -0,0 +1,17 @@
package api
import (
"encoding/hex"
"errors"
"github.com/loyaly/behavision-server/internal/auth"
)
var (
errNoSite = errors.New("no site for this visitor")
errBoom = errors.New(`relation "visitor_profiles" does not exist: boom`)
)
func hashHex(code string) string {
return hex.EncodeToString(auth.HashToken(auth.NormalizeCode(code)))
}

View File

@@ -0,0 +1,114 @@
package api
import (
"net"
"net/http"
"sync"
"time"
)
// Throttle limits failed sign-in attempts.
//
// bcrypt at cost 12 already makes each guess cost ~250 ms, but that is a
// per-attempt cost, not a per-attacker one: a hundred parallel guesses is a
// hundred parallel bcrypts on a 2 vCPU box, which is both a brute force and a
// denial of service on the machine every shop depends on.
//
// Only FAILURES count. A busy shop where staff sign in all morning is not an
// attack, and a limiter that cannot tell the difference gets switched off.
//
// In memory, not in Postgres: this is one process, and a lockout table would
// add a write to the very path an attacker is trying to flood.
type Throttle struct {
// Max failures within Window before refusing.
Max int
Window time.Duration
mu sync.Mutex
hits map[string][]time.Time
now func() time.Time
}
func NewThrottle(max int, window time.Duration) *Throttle {
return &Throttle{
Max: max, Window: window,
hits: make(map[string][]time.Time),
now: func() time.Time { return time.Now() },
}
}
// Allow reports whether a key may attempt again, without recording anything.
func (t *Throttle) Allow(key string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.live(key)) < t.Max
}
// Fail records a failed attempt.
func (t *Throttle) Fail(key string) {
t.mu.Lock()
defer t.mu.Unlock()
t.hits[key] = append(t.live(key), t.now())
}
// Reset clears a key after a success, so one forgotten password in the morning
// does not lock somebody out at lunchtime.
func (t *Throttle) Reset(key string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.hits, key)
}
// live returns the still-relevant attempts and prunes the rest. Pruning on read
// is what keeps the map from growing forever without a sweeper goroutine —
// every key that stops being touched stops existing the next time it is.
func (t *Throttle) live(key string) []time.Time {
cutoff := t.now().Add(-t.Window)
kept := t.hits[key][:0]
for _, at := range t.hits[key] {
if at.After(cutoff) {
kept = append(kept, at)
}
}
if len(kept) == 0 {
delete(t.hits, key)
return nil
}
t.hits[key] = kept
return kept
}
// Sweep drops keys with nothing live left. Called on a timer so an attacker
// spraying a million distinct addresses cannot grow the map without bound
// between requests for those same addresses.
func (t *Throttle) Sweep() {
t.mu.Lock()
defer t.mu.Unlock()
for k := range t.hits {
t.live(k)
}
}
// clientIP prefers the proxy's forwarded address because Traefik terminates
// TLS in front of this, so RemoteAddr is always the proxy.
//
// Trusting X-Forwarded-For is only safe BECAUSE nothing reaches this port
// except through that proxy; exposed directly, a client sets the header itself
// and defeats the limiter. If the listener ever becomes reachable, this must
// change with it.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// Left-most is the original client; the rest are proxies.
for i := 0; i < len(fwd); i++ {
if fwd[i] == ',' {
return trim(fwd[:i])
}
}
return trim(fwd)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

View File

@@ -0,0 +1,79 @@
package api
import (
"net/http/httptest"
"testing"
"time"
)
func TestThrottleBlocksAfterMaxAndForgetsAfterTheWindow(t *testing.T) {
now := time.Unix(1_000_000, 0)
th := NewThrottle(3, time.Minute)
th.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if !th.Allow("k") {
t.Fatalf("blocked after %d failures, max is 3", i)
}
th.Fail("k")
}
if th.Allow("k") {
t.Fatal("still allowed after hitting the maximum")
}
// A different key is a different attacker.
if !th.Allow("other") {
t.Fatal("one key's failures blocked another")
}
now = now.Add(time.Minute + time.Second)
if !th.Allow("k") {
t.Fatal("the window never expired")
}
}
func TestThrottleResetClearsAKey(t *testing.T) {
th := NewThrottle(2, time.Minute)
th.Fail("k")
th.Fail("k")
if th.Allow("k") {
t.Fatal("not blocked")
}
th.Reset("k")
if !th.Allow("k") {
t.Fatal("a successful sign-in did not clear the counter")
}
}
// Pruning happens on read, so a key nobody touches again must not survive a
// sweep. Otherwise an attacker spraying distinct addresses grows the map
// without bound.
func TestThrottleDoesNotGrowForever(t *testing.T) {
now := time.Unix(1_000_000, 0)
th := NewThrottle(5, time.Minute)
th.now = func() time.Time { return now }
for i := 0; i < 1000; i++ {
th.Fail("key" + itoa(i))
}
if len(th.hits) != 1000 {
t.Fatalf("expected 1000 keys, got %d", len(th.hits))
}
now = now.Add(2 * time.Minute)
th.Sweep()
if len(th.hits) != 0 {
t.Fatalf("%d keys survived the sweep", len(th.hits))
}
}
func TestClientIPPrefersTheProxyHeader(t *testing.T) {
r := httptest.NewRequest("POST", "/api/auth/login", nil)
r.RemoteAddr = "10.0.0.5:44321"
if got := clientIP(r); got != "10.0.0.5" {
t.Fatalf("got %q", got)
}
// Traefik terminates TLS in front of this, so RemoteAddr is always the
// proxy and the left-most forwarded address is the real client.
r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := clientIP(r); got != "203.0.113.9" {
t.Fatalf("got %q", got)
}
}

View File

@@ -0,0 +1,585 @@
package api
import "time"
// UserRecord is the row behind a login. It carries the password hash, so it
// must never be serialised - the wire type is User.
type UserRecord struct {
ID string
ClientID string
ClientName string
Email string
FullName string
Role string
Active bool
PasswordHash string
// Found is false when no such address exists. The handler still verifies a
// password against a dummy hash in that case, so an unknown address costs
// the same time as a wrong password.
Found bool
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
ClientID string `json:"client_id"`
Client string `json:"client_name"`
}
type Session struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt string `json:"expires_at"`
User User `json:"user"`
}
// NewSession is a session about to be written. Only hashes cross this
// boundary: the plaintext tokens exist in the handler and in the response, and
// nowhere else.
type NewSession struct {
UserID string
ClientID string
AccessHash []byte
RefreshHash []byte
AccessExpiry time.Time
RefreshExp time.Time
Device string
}
// ReportQuery is a resolved, validated window. Handlers build it; the store
// trusts it. In particular ClientID here always comes from the session.
type ReportQuery struct {
ClientID string
SiteID string // empty = every site this client has
From time.Time
To time.Time
Bucket string // hour | day | week | month
Timezone string // IANA name; buckets are cut in local time
}
type FootfallPoint struct {
Bucket string `json:"bucket"`
Visitors int `json:"visitors"`
New int `json:"new"`
Returning int `json:"returning"`
}
// Totals are computed over the whole window, not summed from the points.
//
// A person who came on Monday and Thursday is two bucket-visitors and one
// unique visitor, so the chart does not add up to the total. That is the
// arithmetic being right, not a bug — and it is why both numbers ship.
type Totals struct {
UniqueVisitors int
Visits int
// Worst fraction_below_gate across the client's sites, and which site that
// was. Worst rather than average: one badly placed camera is a hole in the
// report, and averaging it against three good ones hides exactly the site
// that needs attention.
FractionBelowGate float64
WorstSite string
}
type FootfallReport struct {
From string `json:"from"`
To string `json:"to"`
Bucket string `json:"bucket"`
TZ string `json:"timezone"`
Points []FootfallPoint `json:"points"`
// Total is unique people over the window. Summing Points instead counts a
// returning customer once per bucket they appear in.
Total int `json:"total"`
Visits int `json:"visits"`
// Share of faces the cameras saw that fell below the enrolment gate. A
// footfall figure from a badly placed camera is wrong in a way the figure
// itself cannot show, so it travels with its own confidence.
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
}
type SalesReport struct {
Visitors int `json:"visitors"`
Purchasers int `json:"purchasers"`
Conversion float64 `json:"conversion"`
Revenue float64 `json:"revenue"`
AvgBasket float64 `json:"average_basket"`
Currency string `json:"currency"`
}
type Customer struct {
ID string `json:"id"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
VisitCount int `json:"visit_count"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
HasProfile bool `json:"has_profile"`
HasConsent bool `json:"has_consent"`
}
type VisitRow struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity,omitempty"`
Quality float64 `json:"quality,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
}
type Profile struct {
VisitorID string `json:"visitor_id"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
Gender string `json:"gender"`
DateOfBirth string `json:"date_of_birth"`
Notes string `json:"notes"`
Consent bool `json:"consent"`
}
type PurchaseInput struct {
VisitorID string `json:"visitor_id"`
SiteID string `json:"site_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Items []string `json:"items"`
Source string `json:"source"`
Notes string `json:"notes"`
}
// SiteHealth is what the dashboard needs to distinguish "no customers" from
// "this shop's PC has been unplugged for a week" - two identical rows of zeroes
// with completely different responses.
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
Online bool `json:"online"`
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
LastEventAt string `json:"last_event_at,omitempty"`
RecognitionModel string `json:"recognition_model,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
CamerasUp int `json:"cameras_up"`
CamerasTotal int `json:"cameras_total"`
FractionBelowGate float64 `json:"fraction_below_gate"`
Queued int `json:"queued"`
Dropped int64 `json:"dropped"`
}
// Enrolment is one redeemed install token: which site this PC now is, and the
// broker credentials for it.
type Enrolment struct {
ClientID string
AgentID string
SiteID string
SiteName string
SiteSlug string
MQTTUser string
MQTTPass string
}
type AuditEntry struct {
ClientID string
ActorID string
ActorKind string
Action string
Entity string
EntityID string
Detail map[string]any
}
// UploadTarget is a one-object write permit.
//
// The server picks the key, so a site cannot write into another site's prefix;
// the URL expires; and the ACL is inside the signature, so the agent cannot
// decide to publish the image instead of keeping it private.
type UploadTarget struct {
Key string `json:"key"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ExpiresIn int `json:"expires_in"`
}
// AgentPrincipal is a store PC, authenticated by its own API token. It is not
// a Principal: an agent has no user, no role and no session, and giving it one
// would mean one set of permission checks answering two different questions.
type AgentPrincipal struct {
AgentID string
ClientID string
SiteID string
Slug string // <client>.<site>
Client string
Site string
}
// ---------------------------------------------------------------- arrivals
// Arrival is one person walking in, as a mobile app or a shop screen needs it:
// the visit, who it was, and a link to their face — in ONE row.
//
// This is deliberately not VisitRow. VisitRow answers "when has this customer
// been here before", so it already knows who the person is and needs no photo.
// An arrivals feed answers the opposite question — the caller does not know who
// walked in — so the identity and the picture have to travel with the visit.
// Splitting them would mean a client that sees four people arrive together
// makes nine requests to render one screen, and writes four rows into the image
// audit log to do it.
type Arrival struct {
VisitID string `json:"visit_id"`
// Seq is this visit's position in the feed - assigned by the server when it
// learned of the visit, not by the camera. Exposed because a client that
// wants to know whether it has fallen behind can compare two of them; the
// cursor remains the supported way to page.
Seq int64 `json:"seq"`
OccurredAt string `json:"occurred_at"`
SiteID string `json:"site_id"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity,omitempty"`
Quality float64 `json:"quality,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
// VisitorID is empty when the site sent a count with no template. That is
// real footfall by an unknown person, not an error, and it must still
// appear in the feed - a shop watching arrivals would otherwise see fewer
// people than walked in.
VisitorID string `json:"visitor_id,omitempty"`
// Label is the system's own name ("Visitor 12"); Name is what a human
// typed. Both are sent so the client does not have to guess which is
// present, and so a screen can show the real name and still let staff
// search on the label they see in the desktop app.
Label string `json:"label,omitempty"`
Name string `json:"name,omitempty"`
// Image is always present, never omitted. An absent field would make a
// client treat "photos are switched off for this deployment" and "the
// upload failed" as the same thing, and they need opposite reactions.
Image Image `json:"image"`
// ImageKey is the object-store key, carried from the store to the handler
// that presigns it. `json:"-"` is load-bearing: a raw key names another
// tenant's prefix and is the input to every signing call, so if a handler
// ever forgets to swap it for a signed link the field must be incapable of
// reaching a client. Marshalling is the wrong place to find that out.
ImageKey string `json:"-"`
}
// Image is a short-lived link to a face, or a sentence saying why there is not
// one.
//
// Absence is DATA here, not an error. Images default to off across the whole
// product, so on most deployments every arrival legitimately has no photo; a
// client that renders a failure state for that shows a screen full of red for
// a system working exactly as configured.
type Image struct {
Available bool `json:"available"`
URL string `json:"url,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
// Reason is user-facing prose, present only when Available is false.
Reason string `json:"reason,omitempty"`
// Key is the object-store key, carried from the store to the handler that
// presigns it. `json:"-"` is load-bearing: a raw key names a tenant's
// storage prefix, so a handler that forgets to swap it for a signed link
// must be incapable of leaking it. Marshalling is the wrong place to find
// that out.
Key string `json:"-"`
}
// ArrivalPage is one poll of the feed.
type ArrivalPage struct {
Arrivals []Arrival `json:"arrivals"`
// Cursor is opaque and MUST be echoed back on the next poll. It is the
// only thing that makes the feed lossless: a burst bigger than `limit`
// leaves rows behind, and a caller polling by timestamp alone would skip
// them permanently.
Cursor string `json:"cursor"`
// PolledAt lets a client show "as of ..." without trusting its own clock,
// which on a shop tablet is routinely minutes out.
PolledAt string `json:"polled_at"`
}
// ArrivalQuery is the keyset window the store reads.
type ArrivalQuery struct {
ClientID string
SiteID string
// AfterSeq is the keyset position: the last position this caller has seen.
//
// A POINTER, so that "no cursor" and "the cursor is zero" stay different
// questions. Nil is an app opening for the first time and gets the most
// recent window; zero is a caller deliberately replaying from the
// beginning, which positions start at 1 so nothing is excluded. Collapsing
// the two onto a zero int silently turns a replay request into "give me
// the newest page", which is a client that believes it has caught up on
// history it never received.
AfterSeq *int64
// Since is the timestamp form of the same question, for a caller that has
// no cursor yet but knows when it last looked. Resolved against when the
// server LEARNED of a visit, so it means the same thing as the cursor it
// becomes on the next poll.
Since *time.Time
Limit int
}
// ---------------------------------------------------------------- tenancy
// NewClientInput creates a tenant and the person who owns it, together.
//
// One call, not two, because a client with no owner is a tenant nobody can
// sign into - a half-created state an operator would have to notice and repair
// by hand, on the one screen where they have least context.
type NewClientInput struct {
CompanyName string `json:"company_name"`
Slug string `json:"slug"`
OwnerEmail string `json:"owner_email"`
OwnerName string `json:"owner_name"`
// Password is optional. Empty means "generate one", which is the better
// default: an operator typing a password for someone else invents a weak
// one and then sends it over chat.
Password string `json:"password"`
}
// NewClientResult is the only moment the owner's password exists in readable
// form. It is bcrypt-hashed on the way in and is not recoverable afterwards.
type NewClientResult struct {
ClientID string `json:"client_id"`
Slug string `json:"slug"`
OwnerEmail string `json:"owner_email"`
// Password is shown once. A credential a support engineer can look up
// later is a credential everyone with support access holds.
Password string `json:"password"`
}
// ClientRow is one tenant on the platform-admin list.
type ClientRow struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Sites int `json:"sites"`
Users int `json:"users"`
CreatedAt string `json:"created_at"`
}
// ---------------------------------------------------------------- cameras
// Camera is one camera as head office sees it: how it is configured, and
// whether it is actually working.
//
// Those two are deliberately one object. "Is this camera set up" and "is this
// camera working" are the two halves of the only question anyone asks about a
// camera, and answering them from two places produces a screen where a camera
// can look configured and dead at the same time with no indication which fact
// is stale.
type Camera struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Site string `json:"site,omitempty"`
// CameraID is what the ENGINE knows it by, and what lands in
// visits.camera_id. Stable for the life of the camera.
CameraID string `json:"camera_id"`
Label string `json:"label"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Username string `json:"username"`
// HasPassword, never the password. A camera credential is a live path into
// the camera itself; the only consumer that needs the plaintext is the
// agent for its own site, through a different endpoint. A field called
// `password` that is sometimes populated is how one gets returned by
// accident.
HasPassword bool `json:"has_password"`
MaxWidth int `json:"max_width"`
Tuning map[string]any `json:"tuning,omitempty"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
// Observed, reported by the agent. Connected is a POINTER because "this
// camera is down" and "no agent has told us anything yet" need different
// words on screen, and a bare false says the first when it means the second.
Connected *bool `json:"connected,omitempty"`
LastSeenAt string `json:"last_seen_at,omitempty"`
Snapshot Image `json:"snapshot"`
SnapshotAt string `json:"snapshot_at,omitempty"`
// Check is the last attempt to prove this camera works. Always present so
// a client can tell "never checked" from "checked and failed" without
// guessing from an absent field.
Check CameraCheck `json:"check"`
}
// CameraInput is what an operator submits. Pointers throughout, because a
// blank field means "leave this alone" and an omitted one must not overwrite
// a stored value with an empty string - the same rule the desktop camera form
// already follows.
type CameraInput struct {
CameraID *string `json:"camera_id,omitempty"`
Label *string `json:"label,omitempty"`
Host *string `json:"host,omitempty"`
Port *int `json:"port,omitempty"`
Path *string `json:"path,omitempty"`
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"`
MaxWidth *int `json:"max_width,omitempty"`
Tuning *map[string]any `json:"tuning,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// AgentCamera is the same camera as the AGENT needs it: with the plaintext
// password, because it is the thing that has to connect.
//
// A separate type from Camera on purpose. If one struct served both, the only
// thing stopping a tenant response carrying camera passwords would be
// remembering to blank a field, on every path, forever.
type AgentCamera struct {
CameraID string `json:"camera_id"`
Label string `json:"label"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
MaxWidth int `json:"max_width"`
Tuning map[string]any `json:"tuning,omitempty"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
// Deleted cameras are SENT, not omitted. The agent cannot tell "removed by
// head office" from "not yet adopted" by absence alone, and would re-adopt
// the camera somebody just deleted.
Deleted bool `json:"deleted,omitempty"`
}
// EnrolmentCode is the one-shot code an installer types into a shop PC.
//
// Returned in full exactly once, at the moment it is minted: only a hash is
// stored, so nobody - support included - can look it up again. Losing it costs
// one more code, which is cheap; being able to read one back would mean every
// person with database access could claim a PC into somebody's shop.
type EnrolmentCode struct {
Code string `json:"code"`
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
Label string `json:"label,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
// NewEnrolmentCodeInput is what an operator submits. Both fields optional: a
// label is a note to themselves, and the default lifetime is a week.
type NewEnrolmentCodeInput struct {
Label string `json:"label"`
Days int `json:"days"`
}
// AgentCameraState is the agent reporting back what it observes.
type AgentCameraState struct {
CameraID string `json:"camera_id"`
Connected bool `json:"connected"`
SnapshotKey string `json:"snapshot_key,omitempty"`
}
// AgentCameraReport is one sync from a shop PC: what it sees, and any camera
// configured locally that head office does not know about yet.
type AgentCameraReport struct {
State []AgentCameraState `json:"state,omitempty"`
// Adopt carries cameras found in the engine's own store but absent here.
// Without adoption, switching this feature on would delete the cameras a
// site is already running - including the one it was commissioned with.
Adopt []AgentCamera `json:"adopt,omitempty"`
}
// ---------------------------------------------------------------- checks
// CameraCheck is the state of proving one camera works.
//
// The engine's own words travel through untouched. `verdict`, `headline` and
// `advice` are written for the person standing next to the camera, and
// re-wording them here and again in the browser is how three descriptions of
// one failure drift apart.
type CameraCheck struct {
// Kind is "connection" (can the shop PC open the stream) or "placement"
// (does somebody walking past produce a view worth enrolling). Two
// questions, two answers, because a camera can pass the first and fail the
// second - which is exactly what happened on the Office1 camera for weeks.
Kind string `json:"kind,omitempty"`
// State is requested | running | done. Absent means no check has been run.
State string `json:"state,omitempty"`
RequestedAt string `json:"requested_at,omitempty"`
FinishedAt string `json:"finished_at,omitempty"`
Seconds int `json:"seconds,omitempty"`
OK bool `json:"ok"`
// Verdict, Headline and Advice come from the engine verbatim.
Verdict string `json:"verdict,omitempty"`
Headline string `json:"headline,omitempty"`
Advice []string `json:"advice,omitempty"`
// Detail is whatever else the engine reported - resolution, face counts,
// quality spread. Passed through so a new engine field reaches the UI
// without a schema change on the way.
Detail map[string]any `json:"detail,omitempty"`
// Image is the frame captured during the check: the operator's proof that
// the camera is pointing where they think it is.
Image Image `json:"image"`
}
// CheckRequest is an operator asking for a check.
type CheckRequest struct {
Kind string `json:"kind"`
// Seconds applies to a placement check only. Long enough for somebody to
// walk through the frame and out of it, which is what the check measures.
Seconds int `json:"seconds,omitempty"`
}
// AgentCheckJob is one pending check as the shop PC receives it.
type AgentCheckJob struct {
CameraID string `json:"camera_id"`
Kind string `json:"kind"`
Seconds int `json:"seconds"`
}
// AgentCheckResult is the shop PC reporting back.
type AgentCheckResult struct {
CameraID string `json:"camera_id"`
OK bool `json:"ok"`
Verdict string `json:"verdict,omitempty"`
Headline string `json:"headline,omitempty"`
Advice []string `json:"advice,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
ImageKey string `json:"image_key,omitempty"`
}
// SiteCheck is the end-to-end answer for one shop: not "is a camera plugged
// in" but "is this site actually working".
//
// Assembled from what head office already knows, so it costs no round trip to
// the shop and works even when the PC is off - which is itself one of the
// answers.
type SiteCheck struct {
SiteID string `json:"site_id"`
Site string `json:"site"`
// Steps run in order and each names what to do when it fails. Ordered
// because a later step cannot be judged while an earlier one is failing:
// asking whether cameras see faces on a PC that is switched off produces
// an answer that means nothing.
Steps []CheckStep `json:"steps"`
// OK is true only when every step passed. A partial pass is not a working
// site, and calling it one is how Office1 was signed off.
OK bool `json:"ok"`
}
type CheckStep struct {
Name string `json:"name"`
// Status is pass | warn | fail | unknown. `unknown` is its own state, not a
// failure: "we have not been able to check this" and "this is broken" need
// different reactions from whoever is reading.
Status string `json:"status"`
Detail string `json:"detail"`
Advice string `json:"advice,omitempty"`
}