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
174 lines
5.9 KiB
Go
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`
|