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:
133
server/internal/api/handlers_admin.go
Normal file
133
server/internal/api/handlers_admin.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user