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

@@ -28,6 +28,15 @@ import (
type Provisioner struct {
Pool *pgxpool.Pool
Secrets *secret.Box
// Broker registers a site's login with Mosquitto when set. Without it the
// command prints the password for a hand edit of the passwd file, which
// is the pre-dynamic-security fallback and nothing more.
Broker SiteBroker
}
// SiteBroker mirrors internal/broker's interface without importing it.
type SiteBroker interface {
EnsureSite(ctx context.Context, username, password string) error
}
func (p *Provisioner) CreateClient(ctx context.Context, slug, name string) (string, error) {
@@ -48,6 +57,9 @@ type SiteResult struct {
AgentID string
Username string
Password string
// BrokerRegistered is true when the login was pushed to Mosquitto here,
// so nothing remains for a person to type.
BrokerRegistered bool
}
// CreateSite makes a site, its agent row, and the broker password.
@@ -120,7 +132,16 @@ func (p *Provisioner) CreateSite(ctx context.Context, clientSlug, siteSlug, name
out.AgentID, sealed); err != nil {
return out, err
}
return out, tx.Commit(ctx)
if err := tx.Commit(ctx); err != nil {
return out, err
}
if p.Broker != nil {
if err := p.Broker.EnsureSite(ctx, out.Username, out.Password); err != nil {
return out, fmt.Errorf("site %s created, but the broker did not accept its login: %w", out.Username, err)
}
out.BrokerRegistered = true
}
return out, nil
}
func (p *Provisioner) CreateUser(ctx context.Context, clientSlug, email, role,