// Package api is the request/response half of the server: staff signing in // from the desktop app, reports, the in-store customer form, and a fresh PC // collecting its broker credentials. // // The MQTT consumer in `ingest` is the other half and shares nothing with this // but the database. That separation is deliberate — an agent is authenticated // by the broker and identified by its topic, a person is authenticated by a // password and identified by a session. Merging the two would mean one code // path deciding two very different questions about who is asking. // // Every handler here derives the tenant from the SESSION, never from the // request body. A client_id parameter that the caller can set is a cross-tenant // read waiting for someone to try it. package api import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "io" "log" "net/http" "strconv" "strings" "sync" "time" "github.com/loyaly/behavision-server/internal/auth" ) // Store is what the API needs from the database. Declared here, implemented in // package store, so handlers can be tested against a fake with no Postgres — // the same pattern ingest already uses. type Store interface { // --- identity --- UserByEmail(ctx context.Context, email string) (UserRecord, error) // --- shops --- // CreateSite writes the shop and its sealed broker password in one // transaction and returns the plaintext once, for the broker registration // that must follow. DeleteNewSite is the compensation when that // registration fails: a shop whose PC can enrol but never publish is the // silent failure this whole endpoint exists to end. CreateSite(ctx context.Context, clientID, slug, name, tz string) (NewSite, error) DeleteNewSite(ctx context.Context, clientID, siteID string) error TouchUserLogin(ctx context.Context, userID string) error CreateSession(ctx context.Context, s NewSession) error SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error) SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error) RotateSession(ctx context.Context, sessionID string, s NewSession) error RevokeSession(ctx context.Context, sessionID string) error // Which devices are signed in, and signing one of them out. This is what // an opaque-token session table buys over a JWT, and until these existed // the product paid the cost of that choice without the benefit. UserSessions(ctx context.Context, userID string) ([]DeviceSession, error) RevokeUserSession(ctx context.Context, userID, sessionID string) error RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) // --- team and invitations --- // Registration is by invitation: the code carries the address and the role // so neither can be chosen by whoever redeems it. CreateInvitation(ctx context.Context, in NewInvitation) (Invitation, error) PendingInvitations(ctx context.Context, clientID string) ([]Invitation, error) RevokeInvitation(ctx context.Context, clientID, id string) error InvitationByCode(ctx context.Context, hash []byte) (InvitationPreview, error) // RedeemInvitation spends the code and creates the account in ONE // transaction: a spent invitation with no user behind it is unusable, and a // user with the invitation still open is a second account waiting for // whoever else was forwarded the code. RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error) Team(ctx context.Context, clientID string) ([]TeamMember, error) UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error) // CreateMember inserts an active account into the caller's tenant. The // hash is computed by the handler, so the plaintext never reaches the // store - same boundary invitations and sessions already keep. CreateMember(ctx context.Context, clientID string, in NewMemberInput, hash string) (TeamMember, error) // ResetMemberPassword replaces the hash and revokes every session the // member holds, in one transaction. A reset is what happens after a lost // phone; leaving that phone signed in would defeat it. ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (TeamMember, error) // --- public references --- // Resolving the names people actually use to the uuids the schema stores. // All three answer "" with a nil error when nothing matches; a found id is // never empty, so a miss cannot be confused with a fault. See refs.go. SiteIDBySlug(ctx context.Context, clientID, slug string) (string, error) CameraIDByRef(ctx context.Context, clientID, ref string) (string, error) VisitorIDByNumber(ctx context.Context, clientID string, number int64) (string, error) // --- reports --- Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) Conversion(ctx context.Context, q ReportQuery) (SalesReport, error) SiteHealth(ctx context.Context, clientID string) ([]SiteHealth, error) // --- people --- SearchVisitors(ctx context.Context, clientID, query string, limit int) ([]Customer, error) VisitorHistory(ctx context.Context, clientID, visitorID string, limit int) ([]VisitRow, error) // Arrivals is the live feed: who walked in, newest window or from a cursor. Arrivals(ctx context.Context, q ArrivalQuery) ([]Arrival, error) SaveProfile(ctx context.Context, clientID string, p Profile, actor string) error RecordPurchase(ctx context.Context, clientID string, p PurchaseInput, actor string) error // --- cameras --- Cameras(ctx context.Context, clientID, siteID string) ([]Camera, error) CameraByID(ctx context.Context, clientID, id string) (Camera, error) SaveCamera(ctx context.Context, clientID, siteID, cameraID string, in CameraInput) (Camera, error) DeleteCamera(ctx context.Context, clientID, id string) (Camera, error) // AgentCameras is the only path that decrypts a camera password, and it is // reachable only with that site's own agent token. AgentCameras(ctx context.Context, siteID string) ([]AgentCamera, error) ApplyAgentReport(ctx context.Context, clientID, siteID string, rep AgentCameraReport) error // CameraRef resolves one of a TENANT's cameras to its site and the name the // engine knows it by. Used to prove ownership before anything is streamed. CameraRef(ctx context.Context, clientID, cameraID string) (siteID, engineID string, err error) // CameraRefBySite is the same question asked by an agent, which is // authenticated for a site rather than a tenant. CameraRefBySite(ctx context.Context, siteID, cameraID string) (site, engineID string, err error) // SiteCameraIDs lists a site's camera uuids, for the agent's live poll. SiteCameraIDs(ctx context.Context, siteID string) ([]string, error) // Camera pictures held by this server, for deployments with no object // storage. Where a bucket is configured neither of these is called. PutCameraSnapshot(ctx context.Context, clientID, siteID, cameraID string, jpeg []byte) error CameraSnapshot(ctx context.Context, clientID, cameraID string) ([]byte, time.Time, error) // --- claiming a shop PC --- IssueEnrolmentCode(ctx context.Context, clientID, siteID, actorID, label string, ttl time.Duration) (EnrolmentCode, error) // --- proving a camera works --- RequestCheck(ctx context.Context, clientID, id, kind string, seconds int) error ClaimChecks(ctx context.Context, siteID string) ([]AgentCheckJob, error) RecordCheckResult(ctx context.Context, siteID string, res AgentCheckResult) error ReleaseStaleChecks(ctx context.Context, olderThan time.Duration) error // --- platform administration --- CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error) ListClients(ctx context.Context) ([]ClientRow, error) // --- enrolment --- RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error) SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error) // --- images --- // Face images held by this server, for a deployment with no object // storage. Where a bucket is configured none of these three is called. PutVisitFace(ctx context.Context, clientID, siteID string, jpeg []byte) (string, error) VisitFace(ctx context.Context, clientID, key string) ([]byte, error) DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error) VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error) ForgetVisitor(ctx context.Context, clientID, visitorID string) error Audit(ctx context.Context, e AuditEntry) } type Server struct { Store Store Log *log.Logger Bootstrap BootstrapConfig // Now is injectable so expiry logic is testable without sleeping. Now func() time.Time // Throttle limits failed sign-ins per account, IPThrottle per source // address. Built on first use so a zero-value Server is still safe: an // unlimited login endpoint reached by forgetting one field is not a failure // mode worth leaving open. Throttle *Throttle IPThrottle *Throttle // Blob is object storage. Nil means this deployment stores no images, // which is a supported configuration and the default: the product shipped // without images on purpose, and turning them on changes what the database // is under data-protection law. Blob BlobStore // Assistant answers questions in plain language by calling the same // business questions the screens ask. Nil means this deployment has no // API key, which is supported: the UI hides the panel. Assistant Assistant // Broker registers a shop's login with Mosquitto at the moment the shop is // created. Nil means this deployment cannot create shops through the API // and says so, rather than creating one that can never publish. Broker SiteBroker // Live relays camera frames from a shop PC to whoever is watching, on // demand. Created on first use. Live *LiveHub liveOnce sync.Once // Hub wakes live arrival streams when the MQTT consumer records a visit. // Nil is supported and means the streams fall back to their slow tick - // a server assembled without one is slower, not broken. Hub *Hub once sync.Once } func (s *Server) throttles() (perUser, perIP *Throttle) { s.once.Do(func() { if s.Throttle == nil { s.Throttle = NewThrottle(10, 15*time.Minute) } if s.IPThrottle == nil { // Far looser than the per-account limit, and deliberately so. A // whole shop sits behind one NAT address, so a per-IP limit tight // enough to stop a targeted attack locks out every member of staff // because one of them fumbled their password. The per-ACCOUNT limit // is what actually stops somebody working through a password list; // this is only a backstop against spraying one guess across many // addresses. s.IPThrottle = NewThrottle(60, 15*time.Minute) } }) return s.Throttle, s.IPThrottle } // BootstrapConfig is what a newly enrolled PC is told about the estate. It // comes from the server's own environment, never from the request: an agent // asking where to connect must not be able to influence the answer. type BootstrapConfig struct { MQTTURL string CACert string Models []ModelRef } type ModelRef struct { Name string `json:"name"` URL string `json:"url"` SHA256 string `json:"sha256"` Bytes int64 `json:"bytes"` } func (s *Server) now() time.Time { if s.Now != nil { return s.Now() } return time.Now().UTC() } func (s *Server) logf(format string, v ...any) { if s.Log != nil { s.Log.Printf(format, v...) } } // Routes returns the mux. Patterns use method-qualified paths so a GET to a // write endpoint is a 405 rather than falling through to the catch-all as a // confusing 404. func (s *Server) Routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("POST /api/auth/login", s.handleLogin) mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh) mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout)) mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe)) // Registration. Unauthenticated for the same reason agent enrolment is: // whoever is doing this has no account yet, and requiring one first would // mean shipping a password to everybody who needs one. mux.HandleFunc("GET /api/auth/invitation", s.handleInvitationPreview) mux.HandleFunc("POST /api/auth/register", s.handleRegister) // Devices. A person may list and revoke their own sessions; removing a // colleague's access is a different question, answered by deactivating them // on the team endpoint below. mux.HandleFunc("GET /api/auth/sessions", s.authed(s.handleSessions)) mux.HandleFunc("DELETE /api/auth/sessions/{id}", s.authed(s.handleRevokeSession)) mux.HandleFunc("POST /api/auth/sessions/revoke-others", s.authed(s.handleRevokeOtherSessions)) // --- the people who work here --- mux.HandleFunc("GET /api/team", s.authed(s.handleTeam)) mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember)) mux.HandleFunc("POST /api/team/members", s.authed(s.handleCreateMember)) mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword)) mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations)) mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite)) mux.HandleFunc("DELETE /api/team/invitations/{id}", s.authed(s.handleRevokeInvitation)) mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall)) mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion)) mux.HandleFunc("GET /api/sites", s.authed(s.handleSites)) mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite)) // Cameras, onboarded from head office. The shop PC still does the // connecting - it is the only thing on the camera's network - so these // write desired state that its agent pulls and applies. mux.HandleFunc("GET /api/cameras", s.authed(s.handleCameras)) mux.HandleFunc("POST /api/sites/{site}/cameras", s.authed(s.handleCreateCamera)) mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera)) mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera)) mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.authed(s.handleGetSnapshot)) mux.HandleFunc("GET /api/cameras/{id}/live", s.authed(s.handleWatchLive)) // Prove a camera works: "connection" asks whether the shop PC can open the // stream, "placement" asks whether somebody walking past produces a view // good enough to recognise. Two questions, because a camera passes the // first and fails the second all the time - that is the Office1 case. mux.HandleFunc("POST /api/cameras/{id}/check", s.authed(s.handleRequestCheck)) // The end-to-end answer for one shop, assembled from what head office // already knows - so it works even when the shop PC is off, which is one of // the things it reports. mux.HandleFunc("GET /api/sites/{site}/check", s.authed(s.handleSiteCheck)) mux.HandleFunc("POST /api/sites/{site}/enrolment-code", s.authed(s.handleIssueEnrolmentCode)) // The assistant. Every tool it calls runs as the signed-in user, so it can // only ever see what the person asking could already see. mux.HandleFunc("POST /api/assistant", s.authed(s.handleAssistant)) // The live feed. `visitors` searches a customer list by name; `visits` // answers the question a shop screen or a mobile app actually asks - who // came through the door just now - and carries each person's photo with // them so rendering four simultaneous arrivals is one request, not nine. mux.HandleFunc("GET /api/visits", s.authed(s.handleArrivals)) mux.HandleFunc("GET /api/visits/stream", s.authed(s.handleArrivalStream)) mux.HandleFunc("GET /api/visitors", s.authed(s.handleVisitors)) mux.HandleFunc("GET /api/visitors/{id}/history", s.authed(s.handleVisitorHistory)) mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile)) mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase)) // Platform administration. Not public registration: an open endpoint that // mints tenants is a far larger thing to secure than one behind an account // that already exists. The `provision` CLI remains the bootstrap path, // because creating the first admin cannot require being signed in as one. mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients)) mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient)) // Not session-authenticated: this is how a PC with no credentials gets // some. The enrolment token is the credential. mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol) // Authenticated by the agent's own API token, not a user session. mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL)) // The fallback the agent takes when upload-url answers images_disabled. mux.HandleFunc("POST /api/agent/faces", s.agentAuthed(s.handlePutFace)) // What this shop PC should be running, and what it reports back. mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras)) mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport)) mux.HandleFunc("PUT /api/agent/cameras/{camera}/snapshot", s.agentAuthed(s.handlePutSnapshot)) mux.HandleFunc("GET /api/agent/live", s.agentAuthed(s.handleAgentLiveWanted)) mux.HandleFunc("POST /api/agent/cameras/{camera}/live", s.agentAuthed(s.handleAgentPushLive)) mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks)) mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult)) mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage)) // The bytes of a face this server holds itself. Session-authenticated // rather than a signed link: there is no third party to delegate to, and an // unauthenticated URL would be a way to reach a customer's photograph with // no session at all. mux.HandleFunc("GET /api/faces/{id}", s.authed(s.handleGetFace)) // The erasure path. Destroys the template and the photo; keeps the // anonymous visit counts, which are legitimate aggregate data. mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor)) return mux } // ---------------------------------------------------------------- plumbing type ctxKey int const principalKey ctxKey = 1 // PrincipalFrom returns the authenticated caller. Handlers behind authed() can // rely on it being present. func PrincipalFrom(ctx context.Context) auth.Principal { p, _ := ctx.Value(principalKey).(auth.Principal) return p } func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tok := auth.BearerToken(r) if tok == "" { unauthorized(w, "sign in to continue") return } p, expires, err := s.Store.SessionByAccess(r.Context(), auth.HashToken(tok)) if err != nil { // One message for "no such session" and "revoked": telling the // difference is only useful to someone probing tokens. unauthorized(w, "sign in to continue") return } if s.now().After(expires) { // A distinct code so the client refreshes silently instead of // throwing the user back to a login form every twelve hours. writeErr(w, http.StatusUnauthorized, "token_expired", "your session needs refreshing") return } next(w, r.WithContext(context.WithValue(r.Context(), principalKey, p))) } } func writeJSON(w http.ResponseWriter, code int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) if err := json.NewEncoder(w).Encode(body); err != nil { // Nothing useful left to do: the status line is already sent. return } } // writeCompactJSON writes JSON with no trailing newline. // // Encoder.Encode appends one, which inside an SSE `data:` line closes the event // a frame early. Everywhere else that newline is invisible; here it is a // protocol bug, so the stream gets its own writer rather than a comment on the // shared one asking people to remember. func writeCompactJSON(w io.Writer, body any) error { b, err := json.Marshal(body) if err != nil { return err } _, err = w.Write(b) return err } // writeErr uses the shape the desktop client parses: it shows `message` // verbatim, so that string is user-facing text, not a developer note. func writeErr(w http.ResponseWriter, code int, kind, message string) { writeJSON(w, code, map[string]string{"error": kind, "message": message}) } func unauthorized(w http.ResponseWriter, msg string) { writeErr(w, http.StatusUnauthorized, "unauthorized", msg) } func badRequest(w http.ResponseWriter, msg string) { writeErr(w, http.StatusBadRequest, "bad_request", msg) } func (s *Server) serverError(w http.ResponseWriter, where string, err error) { // The error text stays in the log. A database error surfaced to a shop // floor tells an attacker about the schema and tells the operator nothing // they can act on. s.logf("ERROR %s: %v", where, err) writeErr(w, http.StatusInternalServerError, "server_error", "something went wrong at our end - please try again") } // decode reads a JSON body with a hard size limit. Unknown fields are rejected // so a client sending `client_id` to a handler that ignores it finds out, // rather than believing it took effect. func decode(w http.ResponseWriter, r *http.Request, out any) error { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(out); err != nil { return errors.New("could not read the request: " + err.Error()) } return nil } // decodeOptional is decode for a body where every field has a default. // // An empty body is then a legitimate request - "mint me a code, I have nothing // to say about it" - and answering that with 400 "could not read the request: // EOF" is a confusing failure for the simplest possible call. Not the default, // because for most endpoints an empty body IS the mistake, and silently // treating it as an empty object would let a PUT wipe a profile. func decodeOptional(w http.ResponseWriter, r *http.Request, out any) error { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(out); err != nil { if errors.Is(err, io.EOF) { return nil } return errors.New("could not read the request: " + err.Error()) } return nil } func queryInt(r *http.Request, name string, def, max int) int { v, err := strconv.Atoi(r.URL.Query().Get(name)) if err != nil || v <= 0 { return def } if v > max { return max } return v } func trim(s string) string { return strings.TrimSpace(s) } // looksLikeUUID checks the shape of a path id before it reaches SQL. // // Every id in this schema is a uuid, and `$1::uuid` on a malformed string is a // Postgres cast error - which surfaces as a 500. A mistyped URL is not a server // fault, and answering one with "something went wrong at our end" sends an // operator looking for an outage that is not there. It also means a scanner // walking the API can tell, from the status code alone, which of its guesses // reached a query. func looksLikeUUID(s string) bool { if len(s) != 36 { return false } for i, c := range s { switch i { case 8, 13, 18, 23: if c != '-' { return false } default: isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') if !isHex { return false } } } return true } // ErrNoSecrets means the server has no encryption key, so camera passwords can // be neither stored nor handed out. Declared here so handlers can recognise it // without importing the store package. var ErrNoSecrets = errors.New("this server has no encryption key, so camera passwords cannot be stored") // ErrNoSnapshot means a camera has no stored picture. An ordinary state - a // camera added a minute ago has none - so it is reported as absence, never as // a failure. var ErrNoSnapshot = errors.New("no snapshot for this camera") // BlobStore is what the API needs from object storage. Declared here and // implemented by internal/blob, so the handlers can be tested without a bucket // and so a deployment with images switched off is a nil field rather than a // second code path. type BlobStore interface { Key(client, site, objectID string, at time.Time) string PresignPut(key string, ttl time.Duration) (string, http.Header, error) PresignGet(key string, ttl time.Duration) (string, error) Delete(ctx context.Context, key string) error } // newObjectID names one uploaded image. // // Random rather than derived from the event id: an object key is guessable if // it is derived, and this bucket allows anonymous listing, so a predictable key // would let someone enumerate a shop's customers by date. It is also the only // identifier the agent gets, and it must not encode who the person is. func newObjectID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { // Cannot happen short of a broken kernel, and a predictable key here // would be worse than a failed upload. panic("api: no entropy for an object id: " + err.Error()) } return hex.EncodeToString(b[:]) }