Files
Behavision/server/internal/api/handlers_admin.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

134 lines
4.3 KiB
Go

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
}