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
101 lines
3.5 KiB
Go
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 ""
|
|
}
|