Files
Behavision/server/internal/api/handlers_agent.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

101 lines
3.5 KiB
Go

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 ""
}