Files
Behavision/server/cmd/behavision-server/provision.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

174 lines
5.9 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/loyaly/behavision-server/internal/provision"
"github.com/loyaly/behavision-server/internal/secret"
)
// runProvision handles `behavision-server provision ...`.
//
// Everything it prints that is a secret is printed ONCE and never stored in
// recoverable form afterwards, so the operator has to copy it now. That is the
// point: a credential a support engineer can look up later is a credential
// anyone with support access has.
func runProvision(args []string) error {
if len(args) == 0 {
return errors.New(provisionUsage)
}
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return errors.New("DATABASE_URL is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return err
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}
box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY")
p := &provision.Provisioner{Pool: pool, Secrets: box}
switch args[0] {
case "client":
fs := flag.NewFlagSet("provision client", flag.ContinueOnError)
slug := fs.String("slug", "", "short name used in MQTT topics, e.g. acme")
name := fs.String("name", "", "display name")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *slug == "" || *name == "" {
return errors.New("provision client -slug acme -name \"Acme Retail\"")
}
id, err := p.CreateClient(ctx, *slug, *name)
if err != nil {
return err
}
fmt.Printf("client %s created: %s\n", *slug, id)
return nil
case "site":
fs := flag.NewFlagSet("provision site", flag.ContinueOnError)
client := fs.String("client", "", "client slug")
slug := fs.String("slug", "", "site slug, e.g. store1")
name := fs.String("name", "", "display name")
tz := fs.String("tz", "Asia/Kolkata", "IANA timezone; footfall is bucketed in it")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *client == "" || *slug == "" || *name == "" {
return errors.New("provision site -client acme -slug store1 -name \"Acme Chennai\"")
}
if boxErr != nil {
return boxErr
}
res, err := p.CreateSite(ctx, *client, *slug, *name, *tz)
if err != nil {
return err
}
fmt.Printf("site created: %s\n", res.SiteID)
fmt.Printf("\nAdd this broker user to Mosquitto, then this site can publish:\n\n")
fmt.Printf(" mosquitto_passwd -b /mosquitto/config/passwd %s '%s'\n\n",
res.Username, res.Password)
// The broker keeps a hash; we keep it sealed. Neither side can show it
// again, which is why it is printed here in full.
fmt.Printf("The password is stored encrypted and handed out only at "+
"enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
return nil
case "user":
fs := flag.NewFlagSet("provision user", flag.ContinueOnError)
client := fs.String("client", "", "client slug (omit for a platform admin)")
email := fs.String("email", "", "sign-in address")
role := fs.String("role", "manager", "owner | manager | staff | admin")
name := fs.String("name", "", "full name")
pw := fs.String("password", "", "leave empty to generate one")
if err := fs.Parse(args[1:]); err != nil {
return err
}
id, password, err := p.CreateUser(ctx, *client, *email, *role, *name, *pw)
if err != nil {
return err
}
fmt.Printf("user %s created: %s (%s)\n", *email, id, *role)
if *pw == "" {
fmt.Printf("\n password: %s\n\n", password)
fmt.Printf("Stored only as a bcrypt hash. Copy it now.\n")
}
return nil
case "token":
fs := flag.NewFlagSet("provision token", flag.ContinueOnError)
client := fs.String("client", "", "client slug")
site := fs.String("site", "", "site slug")
label := fs.String("label", "", "note, e.g. \"front counter PC\"")
days := fs.Int("days", 7, "how long the code stays valid")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *client == "" || *site == "" {
return errors.New("provision token -client acme -site store1")
}
code, expires, err := p.IssueEnrolmentToken(ctx, *client, *site, *label,
time.Duration(*days)*24*time.Hour)
if err != nil {
return err
}
fmt.Printf("\n installation code: %s\n\n", code)
fmt.Printf("Valid once, until %s.\n", expires.Format(time.RFC1123))
return nil
case "key":
// JSON by default so it can be piped straight into an env file without
// somebody retyping 44 base64 characters and getting one wrong - and
// -raw for a shell, because the obvious `export KEY=$(... | tail -1)`
// captures the whole JSON object and hands the server a key it cannot
// parse. That was in RUN.md, on the first step of the first setup.
fs := flag.NewFlagSet("provision key", flag.ContinueOnError)
raw := fs.Bool("raw", false, "print only the key, for $(...) in a shell")
if err := fs.Parse(args[1:]); err != nil {
return err
}
k, err := secret.NewKey()
if err != nil {
return err
}
if *raw {
fmt.Println(k)
fmt.Fprintln(os.Stderr,
"\nStore this with the database backups' key material, NOT beside them.")
return nil
}
out, _ := json.Marshal(map[string]string{"BEHAVISION_SECRET_KEY": k})
fmt.Println(string(out))
fmt.Fprintln(os.Stderr,
"\nStore this with the database backups' key material, NOT beside them.\n"+
"Losing it makes every site's broker password unrecoverable;\n"+
"leaking it with a database dump hands them all over.")
return nil
}
return errors.New(provisionUsage)
}
const provisionUsage = `usage:
behavision-server provision key
behavision-server provision client -slug acme -name "Acme Retail"
behavision-server provision site -client acme -slug store1 -name "Chennai" -tz Asia/Kolkata
behavision-server provision user -client acme -email a@acme.com -role manager
behavision-server provision token -client acme -site store1`