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
134 lines
4.3 KiB
Go
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
|
|
}
|