Files
Behavision/desktop/internal/cloud/client.go
Suriyakumarvijayanayagam 3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-05 11:45:42 +05:30

593 lines
19 KiB
Go

// Package cloud talks to the Behavision server at mcp.loyaly.ai.
//
// Everything a store PC sends to head office goes over MQTT; this is the
// request/response half — logging in, reading reports, saving the customer
// form. A store PC never holds database credentials, so every one of these is
// a call the server authorises against the session token.
package cloud
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ErrUnauthorized means the session is gone. The UI shows the login sheet
// again rather than an error dialog - an expired token is an ordinary event,
// not a fault.
var ErrUnauthorized = errors.New("session expired")
type Client struct {
Base string
http *http.Client
mu sync.RWMutex
token string
refresh string
user User
// Held across a whole refresh so concurrent screens cannot each spend the
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
ClientID string `json:"client_id"`
Client string `json:"client_name"`
}
type Session struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}
func New(base string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) SetSession(s Session) {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
}
func (c *Client) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = "", "", User{}
}
func (c *Client) User() User {
c.mu.RLock()
defer c.mu.RUnlock()
return c.user
}
func (c *Client) LoggedIn() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token != ""
}
// do sends a request, refreshing the session once if the access token has
// expired.
//
// The body is marshalled up front and kept, because a retry has to send it
// again and an io.Reader is spent after the first attempt - a bug that only
// shows up twelve hours after a shop PC was last touched, which is the worst
// possible time to find it.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return err
}
}
err := c.send(ctx, method, path, raw, out)
if !errors.Is(err, errTokenExpired) {
return err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)
}
// APIError carries the server's machine-readable code alongside the prose.
//
// Some codes are not failures at all: a customer with no photo is the default
// configuration of this product, not a fault, and a caller cannot tell that
// from the message text. Error() still returns the server's own words, so
// anything that only prints the error is unaffected.
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
// codeOf reports the server's error code, or "" for anything else.
func codeOf(err error) string {
var ae *APIError
if errors.As(err, &ae) {
return ae.Code
}
return ""
}
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
// An expiring access token is an ordinary event that the client handles on its
// own, not something every screen should have to know about.
var errTokenExpired = errors.New("access token expired")
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
var rdr io.Reader
if raw != nil {
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
// The server returns {error, message, detail}; showing `message` puts the
// server's own words in front of the user instead of a status code.
var e struct {
Message string `json:"message"`
Error string `json:"error"`
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
}
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized:
return ErrUnauthorized
case resp.StatusCode >= 400:
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
}
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
// Refresh swaps the refresh token for a new pair.
//
// Serialised behind refreshMu so a screen that fires four polls at once does
// not spend the refresh token four times - the server rotates it on use, so
// three of those four would race and lose, logging the shop out at random.
func (c *Client) Refresh(ctx context.Context) error {
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
c.mu.RLock()
before, refresh := c.token, c.refresh
c.mu.RUnlock()
if refresh == "" {
return ErrUnauthorized
}
var s Session
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
return err
}
c.mu.Lock()
// Another goroutine may have refreshed while this one waited on the lock;
// its tokens are the live ones and must not be overwritten by ours.
if c.token == before {
c.token, c.refresh = s.Token, s.RefreshToken
if s.User.Email != "" {
c.user = s.User
}
}
c.mu.Unlock()
if c.onRefresh != nil {
// So the caller can persist the rotated tokens. Without this a PC that
// refreshes and then reboots comes back holding a refresh token the
// server already invalidated.
c.onRefresh(s)
}
return nil
}
// OnRefresh registers a callback fired whenever the session rotates.
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err) // a map of strings cannot fail to marshal
}
return b
}
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
var s Session
err := c.do(ctx, http.MethodPost, "/api/auth/login",
map[string]string{"email": email, "password": password}, &s)
if err != nil {
return Session{}, err
}
c.SetSession(s)
return s, nil
}
func (c *Client) Me(ctx context.Context) (User, error) {
var u User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
if err == nil {
c.mu.Lock()
c.user = u
c.mu.Unlock()
}
return u, err
}
// Logout revokes the session server-side as well as forgetting it here.
// Clearing only the local copy leaves a live token on a machine somebody is
// about to hand back.
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Clear()
return err
}
// Session returns the current tokens so the caller can persist them.
func (c *Client) Session() Session {
c.mu.RLock()
defer c.mu.RUnlock()
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
}
// Bootstrap is what a freshly installed PC asks for after the operator logs
// in: which models to fetch, and the broker credentials for this site. The
// installer ships none of this, so a leaked build hands out nothing.
type Bootstrap struct {
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
SiteSlug string `json:"site_slug"`
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
// and the server derives ClientSlug from the broker username so the two
// cannot disagree with the broker's ACL.
ClientSlug string `json:"client_slug"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
// AgentToken is this PC's own credential for the HTTPS API - asking for an
// image upload URL, pulling its camera list. Not the broker password: they
// authenticate different things, so rotating one must not break the other.
AgentToken string `json:"agent_token"`
CACert string `json:"ca_cert"`
Models []Model `json:"models"`
}
type Model struct {
Name string `json:"name"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
}
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
var b Bootstrap
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
map[string]string{"site_token": siteToken}, &b)
}
type FootfallPoint struct {
Bucket string `json:"bucket"`
Visitors int `json:"visitors"`
New int `json:"new"`
Returning int `json:"returning"`
}
type FootfallReport struct {
From string `json:"from"`
To string `json:"to"`
Bucket string `json:"bucket"`
TZ string `json:"timezone"`
Points []FootfallPoint `json:"points"`
// Total is unique people over the whole window; Visits counts every
// appearance. Summing Points gives neither - a customer who came on Monday
// and Thursday is one Total and two bucket-visitors - so both ship rather
// than letting a screen add up the chart and call it a headcount.
Total int `json:"total"`
Visits int `json:"visits"`
// Share of faces the cameras saw that fell below the enrolment gate. A
// footfall figure from a badly placed camera is wrong in a way nobody can
// see, so the number ships with its own confidence.
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
}
// SiteHealth distinguishes "no customers" from "this shop's PC has been
// unplugged for a week" - two identical rows of zeroes with completely
// different responses.
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
Online bool `json:"online"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
LastEventAt string `json:"last_event_at"`
RecognitionModel string `json:"recognition_model"`
AgentVersion string `json:"agent_version"`
CamerasUp int `json:"cameras_up"`
CamerasTotal int `json:"cameras_total"`
FractionBelowGate float64 `json:"fraction_below_gate"`
Queued int `json:"queued"`
Dropped int64 `json:"dropped"`
}
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
var out []SiteHealth
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
}
// Visit is one appearance in a customer's timeline.
type Visit struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Quality float64 `json:"quality"`
Attributes map[string]any `json:"attributes"`
}
// Photo is a customer's face image, or a plain statement that there isn't one.
//
// Absence is modelled as data rather than as an error because it is the
// ordinary case: images are off by default, so most deployments answer
// "no photo" for every customer forever. Returning an error there would put a
// red failure box on screen for a system working exactly as configured, and a
// UI that cries wolf is a UI whose real errors get ignored.
type Photo struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in"`
Available bool `json:"available"`
Reason string `json:"reason"`
// Auth is set by the server when the URL is one of its own endpoints and
// needs this session's bearer, rather than a presigned object-store link
// that carries its own signature. It never reaches the front end - see
// VisitorImage, which resolves it here.
Auth bool `json:"auth"`
}
// VisitorImage fetches a short-lived signed link to this customer's photo.
//
// The link expires (the server decides how soon, and says so), so it is
// fetched when a screen opens rather than cached alongside the customer.
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
var out Photo
err := c.do(ctx, http.MethodGet,
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
if err != nil {
switch codeOf(err) {
case "no_image":
return Photo{Reason: "No photo of this customer has been captured."}, nil
case "images_disabled":
return Photo{Reason: "This system is not storing customer photos."}, nil
}
return Photo{}, err
}
out.Available = out.URL != ""
// A deployment with no object storage serves the photo from the API itself,
// which means a RELATIVE url that needs this session's bearer. Neither
// works in the window: a webview <img> resolves a relative src against
// wails://, not against the cloud, and it cannot send an Authorization
// header at all - so handing it straight through renders a broken picture
// on exactly the deployments that have just started storing photos.
//
// Fetched here and passed as a data: URI. The alternative is a local proxy
// inside this process holding the session, which is a second authenticated
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
// server already records the read where the link was handed out.
if out.Available && out.Auth {
data, err := c.fetchImage(ctx, out.URL)
if err != nil {
// The record itself is worth far more than the picture, so this is
// an absence with a reason rather than a failure that blanks the
// customer - the same rule the whole image path follows.
return Photo{Reason: "That photo could not be loaded."}, nil
}
out.URL = data
out.Auth = false
}
return out, nil
}
// fetchImage reads an image this server holds itself and returns a data: URI.
//
// Deliberately not routed through send(): that decodes JSON into `out`, and
// these are bytes. It shares the token and the expiry retry, because a sheet
// opened twelve hours after the last one must not show a broken photo.
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
body, err := c.imageBytes(ctx, path)
if errors.Is(err, errTokenExpired) {
if rerr := c.Refresh(ctx); rerr != nil {
return "", rerr
}
body, err = c.imageBytes(ctx, path)
}
if err != nil {
return "", err
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
}
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
// this is a different file or a fault, and a shop PC should not spend its
// memory finding that out.
const maxPhotoBytes = 4 << 20
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
if err != nil {
return nil, err
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
var e struct {
Error string `json:"error"`
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
if e.Error == "token_expired" {
return nil, errTokenExpired
}
return nil, ErrUnauthorized
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("photo: %s", resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
}
// ForgetVisitor erases a customer: face template, photo and profile.
//
// Irreversible by design — a soft-deleted face template is a retained
// photograph by another name, because template inversion reconstructs a
// recognisable face from it. The server refuses the whole request rather than
// report a partial erasure, so an error here means nothing was deleted.
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete,
"/api/visitors/"+url.PathEscape(id), nil, nil)
}
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
var out []Visit
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
nil, &out)
}
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
var r FootfallReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
nil, &r)
}
type SalesReport struct {
Visitors int `json:"visitors"`
Purchasers int `json:"purchasers"`
Conversion float64 `json:"conversion"`
Revenue float64 `json:"revenue"`
AvgBasket float64 `json:"average_basket"`
Currency string `json:"currency"`
}
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
var r SalesReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
}
type Customer struct {
ID string `json:"id"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
VisitCount int `json:"visit_count"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
HasProfile bool `json:"has_profile"`
HasConsent bool `json:"has_consent"`
}
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
var out []Customer
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
url.QueryEscape(query), limit), nil, &out)
}
// Profile is the in-store form. PUT rather than POST: a staff member
// resubmitting on a bad connection must not create a second record for the
// same person.
type Profile struct {
VisitorID string `json:"visitor_id"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
Gender string `json:"gender"`
DateOfBirth string `json:"date_of_birth"`
Notes string `json:"notes"`
Consent bool `json:"consent"`
}
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
return c.do(ctx, http.MethodPut,
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
}
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
amount float64, items []string, notes string) error {
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
"visitor_id": visitorID, "amount": amount,
"items": items, "source": "manual", "notes": notes,
}, nil)
}