Files
Behavision/server/internal/api/handlers_sites.go
Suriyakumarvijayanayagam 4c750cb2ac 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
2026-09-19 11:55:26 +05:30

108 lines
3.9 KiB
Go

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)
}