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

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