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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
107
server/internal/api/handlers_sites.go
Normal file
107
server/internal/api/handlers_sites.go
Normal 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)
|
||||
}
|
||||
110
server/internal/api/sites_test.go
Normal file
110
server/internal/api/sites_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user