Opening a shop is an API call; the broker learns of it in the same request

The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-19 11:55:26 +05:30
parent 5f83a1077d
commit 4c750cb2ac
21 changed files with 1310 additions and 54 deletions

View File

@@ -36,6 +36,15 @@ import (
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)
@@ -168,6 +177,10 @@ type Server struct {
// 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
@@ -264,6 +277,7 @@ func (s *Server) Routes() *http.ServeMux {
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

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"net/http"
"strings"
"sync"
@@ -261,6 +262,32 @@ func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, e
return f.sales, nil
}
func (f *fakeStore) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sites {
if s.Slug == slug {
return NewSite{}, &pgconn.PgError{Code: "23505"}
}
}
id := "site-" + slug
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
}
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.sites[:0]
for _, s := range f.sites {
if s.SiteID != siteID {
kept = append(kept, s)
}
}
f.sites = kept
return nil
}
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
return f.sites, nil
}

View File

@@ -0,0 +1,107 @@
package api
import (
"context"
"errors"
"net/http"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// SiteBroker is the broker-side half of creating a shop. It is the
// internal/broker package's interface, redeclared here so this package does
// not import a paho dependency for the sake of one method.
type SiteBroker interface {
EnsureSite(ctx context.Context, username, password string) error
DeleteSite(ctx context.Context, username string) error
}
// Same rule the database enforces (sites_slug_format), checked here so the
// caller gets a sentence instead of a constraint name.
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`)
// POST /api/sites - an owner opens a shop.
//
// Until this existed a shop was `provision site` on the server's command line
// followed by a hand edit of the broker's password file. That made every new
// branch a support ticket, and it was the last piece of onboarding that could
// not be done from the product. The row and the broker login are created
// together here; if the broker will not take the login, the row is removed
// again and the caller is told, because a shop that exists in the database and
// not on the broker is one whose PC enrols fine and never delivers a visit.
func (s *Server) handleCreateSite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
// Owner, not manager: a shop is a billing and tenancy object, not a
// setting. Managers can set up the PC and cameras once it exists.
if p.Role != "owner" || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can open a new shop.")
return
}
if s.Broker == nil {
writeErr(w, http.StatusServiceUnavailable, "broker_unavailable",
"This server is not connected to a broker that can register shops. Contact support.")
return
}
var in NewSiteInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
in.Name = clip(trim(in.Name), 120)
if in.Name == "" {
badRequest(w, "Give the shop a name.")
return
}
in.Slug = slugify(in.Slug)
if in.Slug == "" {
in.Slug = slugify(in.Name)
}
if !slugRe.MatchString(in.Slug) {
badRequest(w, "The short name must be 3-32 characters: lower-case letters, digits and dashes.")
return
}
tz := strings.TrimSpace(in.Timezone)
if tz == "" {
tz = "Asia/Kolkata"
}
if _, err := time.LoadLocation(tz); err != nil {
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
return
}
site, err := s.Store.CreateSite(r.Context(), p.ClientID, in.Slug, in.Name, tz)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeErr(w, http.StatusConflict, "conflict", "A shop with that short name already exists.")
return
}
if errors.Is(err, ErrNoSecrets) {
writeErr(w, http.StatusServiceUnavailable, "no_encryption_key",
"This server has no encryption key, so a shop's broker password cannot be stored. Contact support.")
return
}
s.serverError(w, "create site", err)
return
}
if err := s.Broker.EnsureSite(r.Context(), site.Username, site.Password); err != nil {
s.logf("create site %s: broker registration failed, removing the row: %v", site.Slug, err)
if derr := s.Store.DeleteNewSite(r.Context(), p.ClientID, site.SiteID); derr != nil {
s.logf("create site %s: could not remove the row after broker failure: %v", site.Slug, derr)
}
writeErr(w, http.StatusBadGateway, "broker_unavailable",
"The broker did not accept the new shop, so it was not created. Try again in a moment; if it keeps failing, contact support.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "site.created", Entity: "site", EntityID: site.SiteID,
Detail: map[string]any{"slug": site.Slug, "name": site.Name, "timezone": site.Timezone},
})
writeJSON(w, http.StatusCreated, site)
}

View File

@@ -0,0 +1,110 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
)
// fakeBroker records what the server asked the broker to do.
type fakeBroker struct {
ensured map[string]string
deleted []string
fail error
}
func (b *fakeBroker) EnsureSite(_ context.Context, user, pass string) error {
if b.fail != nil {
return b.fail
}
if b.ensured == nil {
b.ensured = map[string]string{}
}
b.ensured[user] = pass
return nil
}
func (b *fakeBroker) DeleteSite(_ context.Context, user string) error {
b.deleted = append(b.deleted, user)
return nil
}
func ownerSession(t *testing.T, s *Server, fs *fakeStore) Session {
t.Helper()
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true,
})
return login(t, s, "owner@acme.com", "correct horse battery")
}
func TestAnOwnerOpensAShopAndTheBrokerLearnsOfIt(t *testing.T) {
s, fs := newServer(t)
b := &fakeBroker{}
s.Broker = b
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Acme Bengaluru!"})
if rec.Code != http.StatusCreated {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["slug"] != "acme-bengaluru" {
t.Errorf("slug not derived from the name: %v", out["slug"])
}
if _, leaked := out["password"]; leaked {
t.Fatal("the broker password was serialised")
}
if b.ensured["acme.acme-bengaluru"] == "" {
t.Fatalf("broker was not told about the shop: %+v", b.ensured)
}
if len(fs.sites) != 1 {
t.Fatalf("expected one site, have %d", len(fs.sites))
}
}
func TestABrokerFailureLeavesNoHalfMadeShop(t *testing.T) {
s, fs := newServer(t)
s.Broker = &fakeBroker{fail: errors.New("no answer on the control topic")}
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Ghost"})
if rec.Code != http.StatusBadGateway {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.sites) != 0 {
t.Fatalf("a shop the broker never accepted was kept: %+v", fs.sites)
}
}
func TestAManagerCannotOpenAShop(t *testing.T) {
s, fs := newServer(t)
s.Broker = &fakeBroker{}
seedUser(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Nope"})
if rec.Code != http.StatusForbidden {
t.Fatalf("manager opened a shop: %d", rec.Code)
}
}
func TestADuplicateShortNameIsAConflict(t *testing.T) {
s, fs := newServer(t)
s.Broker = &fakeBroker{}
seedSite(fs)
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Again", "slug": "chennai"})
if rec.Code != http.StatusConflict {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
func TestNoBrokerConfiguredSaysSo(t *testing.T) {
s, fs := newServer(t)
sess := ownerSession(t, s, fs)
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Shop"})
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}

View File

@@ -160,6 +160,30 @@ type PurchaseInput struct {
// SiteHealth is what the dashboard needs to distinguish "no customers" from
// "this shop's PC has been unplugged for a week" - two identical rows of zeroes
// with completely different responses.
// NewSiteInput is what an owner types to open a shop. The slug is derived
// from the name when absent, because it becomes the shop PC's identity and
// an MQTT topic segment, and a person asked to invent one invents a bad one.
type NewSiteInput struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// NewSite is the created shop plus the broker login the server registered for
// it. The password is sealed in the database and handed to a shop PC at
// enrolment; it is NOT in the API response, because nobody needs to see it -
// the enrolment code is the credential a person handles.
type NewSite struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
// Username is the broker login; Password is returned to the caller in
// Go only, for the broker registration, and never serialised.
Username string `json:"broker_username"`
Password string `json:"-"`
}
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`