From 47bbb0cca6a90ee3105f559b6f184e4e7a7597d6 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:31:19 +0200 Subject: [PATCH 1/4] broker: a generic module account, scoped by emits and consumes (ADR 0048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateModuleAccount gives an assigned module its own broker account whose permissions ARE its manifest: declare and read its own ..events queue, read the events exchange to bind onto if it consumes, write the events exchange only if it emits. The account name carries the node (sealed per machine), the permissions carry the module (one cannot read another's queue). The builder becomes one instance of this rule rather than a separate kind. EnsureEventExchanges declares the bus the substrate owns — mesh.events, mesh.rpc, mesh.events.dead + a retention queue — idempotently, since a module account may not declare an exchange. Scope tested as patterns (no broker needed), and every management call verified against a real LavinMQ. Honest limit recorded in the code: LavinMQ has no topic permissions, so ADR 0047's emit-origin reservation (module..*) is stamped by the sdk, not enforced by the broker; a pure consumer like the audit logger is unaffected. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- internal/broker/management.go | 102 +++++++++++++++++++++++++ internal/broker/module_account_test.go | 96 +++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 internal/broker/module_account_test.go diff --git a/internal/broker/management.go b/internal/broker/management.go index 2873408..caf8739 100644 --- a/internal/broker/management.go +++ b/internal/broker/management.go @@ -67,6 +67,104 @@ const ExchangeName = "mesh" // constant that differed would be caught by the test that asserts they agree. const BuildQueueName = "builds" +// The events bus (novox/hq ADR 0047): one topic exchange every event rides, a second for tool +// RPC kept apart, and a dead-letter home for a poison event. The substrate owns these — a module's +// account cannot declare them, only bind its own queue to the events one. +const ( + EventsExchangeName = "mesh.events" + RPCExchangeName = "mesh.rpc" + DeadExchangeName = "mesh.events.dead" +) + +// ModuleQueueFor is the durable queue a module consumes its events from — one per module per node +// (novox/hq ADR 0047), named so the account that may read it is exactly this module's. +func ModuleQueueFor(node, module string) string { return node + "." + module + ".events" } + +// modulePermissions is a module's authority on the bus, derived from its manifest (novox/hq +// ADR 0048): what it consumes and what it emits, and nothing else. Pure, so the scope is tested as +// patterns without a broker — the way a builder's is. +// +// A note on the limit: the broker's write permission is per exchange, not per routing key (LavinMQ +// has no topic permissions), so an emitting module is granted the events exchange whole. ADR 0047's +// origin reservation — a module publishes only under `module..*` — is stamped by the sdk, not +// enforced here; that gap is the broker's, and is recorded rather than hidden. A pure consumer like +// the audit logger is unaffected: it is granted no write to the exchange at all. +func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) { + queue := regexp.QuoteMeta(ModuleQueueFor(node, module)) + events := regexp.QuoteMeta(EventsExchangeName) + + // Declare only its own queue. + configure = "^" + queue + "$" + + // Write to its own queue — binding a queue to an exchange is a write on the queue — and to the + // events exchange only if it emits. + writes := []string{queue} + if len(emits) > 0 { + writes = append(writes, events) + } + write = "^(" + strings.Join(writes, "|") + ")$" + + // Read its own queue to consume it, and the events exchange to bind onto, only if it consumes. + reads := []string{queue} + if len(consumes) > 0 { + reads = append(reads, events) + } + read = "^(" + strings.Join(reads, "|") + ")$" + return configure, write, read +} + +// CreateModuleAccount gives an assigned module its own broker account, scoped by what it emits and +// consumes (novox/hq ADR 0048). The account name carries the node so the same module on two machines +// holds two accounts, each sealed to its own; the permissions carry the module so one module cannot +// read another's queue. Generic — the builder is one instance of this rule, not a separate kind. +func (m *Management) CreateModuleAccount(ctx context.Context, node, module, password string, emits, consumes []string) (string, error) { + if !safeName.MatchString(node) { + return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", node) + } + if !safeName.MatchString(module) { + return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", module) + } + account := node + "-" + module + if !safeName.MatchString(account) { + return "", fmt.Errorf("%q is not a usable broker account name", account) + } + + if err := m.put(ctx, "/api/users/"+url.PathEscape(account), + map[string]string{"password": password, "tags": ""}); err != nil { + return "", fmt.Errorf("cannot create the broker account for %s on %s: %w", module, node, err) + } + + configure, write, read := modulePermissions(node, module, emits, consumes) + if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(account), map[string]string{ + "configure": configure, "write": write, "read": read, + }); err != nil { + return "", fmt.Errorf("cannot scope the broker account for %s on %s: %w", module, node, err) + } + return account, nil +} + +// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The +// substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange +// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison +// event for inspection, which is the whole reason the trail exists. +func (m *Management) EnsureEventExchanges(ctx context.Context) error { + for _, exchange := range []string{EventsExchangeName, RPCExchangeName, DeadExchangeName} { + if err := m.put(ctx, "/api/exchanges/%2f/"+url.PathEscape(exchange), + map[string]any{"type": "topic", "durable": true}); err != nil { + return fmt.Errorf("cannot declare the %s exchange: %w", exchange, err) + } + } + if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(DeadExchangeName), + map[string]any{"durable": true}); err != nil { + return fmt.Errorf("cannot declare the dead-letter queue: %w", err) + } + if err := m.post(ctx, "/api/bindings/%2f/e/"+url.PathEscape(DeadExchangeName)+ + "/q/"+url.PathEscape(DeadExchangeName), map[string]string{"routing_key": "#"}); err != nil { + return fmt.Errorf("cannot bind the dead-letter queue: %w", err) + } + return nil +} + // CreateNodeAccount gives a node its own broker account, with the token's secret as the password. // // Scoped so a node can reach its own queue and the one exchange, and nothing else. The patterns @@ -167,6 +265,10 @@ func (m *Management) put(ctx context.Context, path string, body any) error { return m.do(ctx, http.MethodPut, path, body) } +func (m *Management) post(ctx context.Context, path string, body any) error { + return m.do(ctx, http.MethodPost, path, body) +} + func (m *Management) get(ctx context.Context, path string) ([]byte, error) { request, err := m.request(ctx, http.MethodGet, path, nil) if err != nil { diff --git a/internal/broker/module_account_test.go b/internal/broker/module_account_test.go new file mode 100644 index 0000000..d14bce8 --- /dev/null +++ b/internal/broker/module_account_test.go @@ -0,0 +1,96 @@ +package broker_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/novox/mesh-control/internal/broker" +) + +// The audit logger consumes everything and emits nothing. Its account must let it declare and read +// its own queue and read the events exchange to bind onto — and must not reach another module's +// queue, nor grant any write to the events exchange (novox/hq ADR 0048). +func TestAConsumerReadsItsOwnQueueAndTheEventsExchangeAndNoOthers(t *testing.T) { + // Rebuilt through CreateModuleAccount's own path by asking for the same scope it would apply. + // The queue this module reads: + mine := broker.ModuleQueueFor("anchor", "audit-logger") + other := broker.ModuleQueueFor("anchor", "plex") + + read := scope(t, "read", "anchor", "audit-logger", nil, []string{"#"}) + if !read.MatchString(mine) { + t.Error("the audit logger may not read its own queue, so it consumes nothing") + } + if !read.MatchString(broker.EventsExchangeName) { + t.Error("the audit logger may not read the events exchange, so it cannot bind onto it") + } + if read.MatchString(other) { + t.Error("the audit logger may read another module's queue") + } + + // It emits nothing, so it is granted no write to the events exchange — only its own queue, to bind. + write := scope(t, "write", "anchor", "audit-logger", nil, []string{"#"}) + if write.MatchString(broker.EventsExchangeName) { + t.Error("a pure consumer was granted write to the events exchange") + } + if !write.MatchString(mine) { + t.Error("the audit logger may not write to its own queue, so it cannot bind it") + } +} + +// An emitter is granted the events exchange to write; a consumer is not. +func TestAnEmitterMayWriteTheEventsExchangeAndAConsumerMayNot(t *testing.T) { + emitter := scope(t, "write", "anchor", "umami", []string{"module.umami.site.created"}, nil) + if !emitter.MatchString(broker.EventsExchangeName) { + t.Error("an emitting module may not write the events exchange, so it cannot emit") + } +} + +// scope reconstructs one of the three permission patterns CreateModuleAccount would apply, by +// reading it back from a captured request against a stub management API. +func scope(t *testing.T, which, node, module string, emits, consumes []string) *regexp.Regexp { + t.Helper() + pat := capturePermission(t, which, node, module, emits, consumes) + re, err := regexp.Compile(pat) + if err != nil { + t.Fatalf("the %s pattern does not compile: %v", which, err) + } + return re +} + +// capturePermission runs CreateModuleAccount against a stub management API and returns the pattern +// it set for `which` ("configure"/"write"/"read"). The scope is tested where it is applied, not +// reconstructed by the test — so a change to the mapping cannot pass a test that hard-codes the old +// one. +func capturePermission(t *testing.T, which, node, module string, emits, consumes []string) string { + t.Helper() + var captured map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/permissions/") { + _ = json.NewDecoder(r.Body).Decode(&captured) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv(broker.ManagementVar, server.URL) + m, err := broker.ManagementFromEnvironment() + if err != nil { + t.Fatalf("stub management not usable: %v", err) + } + if _, err := m.CreateModuleAccount(context.Background(), node, module, "pw", emits, consumes); err != nil { + t.Fatalf("CreateModuleAccount: %v", err) + } + if captured == nil { + t.Fatal("no permissions were set") + } + pattern, ok := captured[which] + if !ok { + t.Fatalf("no %s permission was set; got %v", which, captured) + } + return pattern +} -- 2.54.0 From f41d280e66fcac49ee299b615393d5a134150be8 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:33:03 +0200 Subject: [PATCH 2/4] =?UTF-8?q?cli:=20module=20issue=20=E2=80=94=20deliver?= =?UTF-8?q?=20a=20module=20its=20scoped=20broker=20account=20(ADR=200048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'module issue --node ' looks up the module's emits/consumes from the catalogue, ensures the bus exchanges exist, creates its scoped account (CreateModuleAccount), and seals an amqps {url,fingerprint} to the node as the module's broker own-secret — the same delivery as 'builder issue', now generic. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/main.go | 1 + cmd/mesh-control/modules.go | 77 ++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 43a58d6..613f709 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -135,6 +135,7 @@ func usage() { module list what modules this mesh knows about module moved the source has a newer commit than the mesh built module forget remove one, unless a node is running it + module issue --node a broker account for a module, scoped to its emits and consumes status [--json] what is wrong, what is quiet, and what is out of date board [--listen ADDR] the same three questions, as a page that holds nothing api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index cfa81cd..837b26e 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "flag" @@ -10,6 +12,7 @@ import ( "sort" "strings" + "github.com/novox/mesh-control/internal/broker" "github.com/novox/mesh-control/internal/catalogue" "github.com/novox/mesh-control/internal/inventory" "github.com/novox/mesh-control/internal/overlay" @@ -194,8 +197,80 @@ func moduleCommand(ctx context.Context, args []string) error { fmt.Printf("%s forgotten\n", args[1]) return nil + case "issue": + // A module's broker account, scoped by its emits and consumes (novox/hq ADR 0048) and + // sealed to the machine that will run it — the generic case the builder was the first of. + set := flag.NewFlagSet("module issue", flag.ContinueOnError) + forNode := set.String("node", "", + "the machine that will run it, so the credential is delivered instead of printed") + positionals, err := parseAround(set, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("module issue --node ") + } + module := positionals[0] + if *forNode == "" { + return errors.New("module issue needs --node: a module's account is sealed to the " + + "machine that runs it, and the mesh cannot read it back to print") + } + + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + m, ok := shelf[module] + if !ok { + return fmt.Errorf("this mesh knows no module %q; `module add` it first", module) + } + + management, err := broker.ManagementFromEnvironment() + if err != nil { + return err + } + // The substrate owns the bus; make sure it exists before a module binds onto it. + if err := management.EnsureEventExchanges(ctx); err != nil { + return err + } + + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + password := base64.RawURLEncoding.EncodeToString(secret) + account, err := management.CreateModuleAccount(ctx, *forNode, module, password, m.Emits, m.Consumes) + if err != nil { + return err + } + + known, err := broker.FromEnvironment() + if err != nil { + return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err) + } + // The URL and what verifies the broker, together — a mesh's broker presents its own + // certificate, in no public trust store, so a URL alone fails at TLS (as `builder issue`). + held, err := json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + }{ + URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, known.Address), + Fingerprint: known.Fingerprint, + }) + if err != nil { + return err + } + if err := inv.AcceptSecretForModule(ctx, *forNode, module, "broker", string(held)); err != nil { + return err + } + fmt.Printf("broker account %s created for %s, scoped to what it emits and consumes\n", + account, module) + fmt.Printf(" sealed to %s. It arrives with the next push — `push %s` to send it\n", + *forNode, *forNode) + return nil + default: - return fmt.Errorf("module has no %q; it has add, list, moved and forget", args[0]) + return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0]) } } -- 2.54.0 From 64496f03353219655c98dbd4da78c41c756f48c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:51:17 +0200 Subject: [PATCH 3/4] broker: the substrate pre-declares a consumer's dead-lettered queue (ADR 0048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LavinMQ refuses a non-administrator declaring a queue with a dead-letter exchange, so a scoped module cannot make its own. EnsureModuleQueue declares ..events with its DLX as the mesh, and 'module issue' does so for a consuming module — the runtime then passively checks it rather than declaring. Verified against a real broker: the scoped account binds and consumes the pre-declared queue. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/modules.go | 7 +++++++ internal/broker/management.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index 837b26e..743a192 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -243,6 +243,13 @@ func moduleCommand(ctx context.Context, args []string) error { if err != nil { return err } + // A consumer's queue, with its dead-letter, is the substrate's to declare — its own account + // may not (ADR 0048). Made now, so it exists before the module binds onto it. + if len(m.Consumes) > 0 { + if err := management.EnsureModuleQueue(ctx, *forNode, module); err != nil { + return err + } + } known, err := broker.FromEnvironment() if err != nil { diff --git a/internal/broker/management.go b/internal/broker/management.go index caf8739..3270826 100644 --- a/internal/broker/management.go +++ b/internal/broker/management.go @@ -143,6 +143,21 @@ func (m *Management) CreateModuleAccount(ctx context.Context, node, module, pass return account, nil } +// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently. +// The substrate declares it because a scoped module account may not: the broker refuses a queue with +// a dead-letter exchange to a non-administrator (novox/hq ADR 0048), so a consumer passively checks +// the queue the mesh made rather than declaring its own. +func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error { + queue := ModuleQueueFor(node, module) + if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(queue), map[string]any{ + "durable": true, + "arguments": map[string]any{"x-dead-letter-exchange": DeadExchangeName}, + }); err != nil { + return fmt.Errorf("cannot declare the queue for %s on %s: %w", module, node, err) + } + return nil +} + // EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The // substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange // with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison -- 2.54.0 From 931ca6f01e699d08574ee7748d4b13291e96cfb3 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:53:23 +0200 Subject: [PATCH 4/4] cli: module issue seals the node and module into the credential So the runtime knows the identity the account was scoped to, without a manifest naming the node. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/modules.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index 743a192..3d10bd7 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -260,9 +260,15 @@ func moduleCommand(ctx context.Context, args []string) error { held, err := json.Marshal(struct { URL string `json:"url"` Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` }{ URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, known.Address), Fingerprint: known.Fingerprint, + // The node and module the account is for, so the runtime names its queue as the mesh + // scoped it (..events) without a manifest having to interpolate a node. + Node: *forNode, + Module: module, }) if err != nil { return err -- 2.54.0