Merge pull request 'A module's process may hold a bus account of its own, and a module stream may be a work queue (ADR 0436, issues 1170, 1177-1181)' (#390) from feat/mail-sender-controller into main

This commit was merged in pull request #390.
This commit is contained in:
2026-10-19 02:07:51 +00:00
30 changed files with 2352 additions and 48 deletions
+103 -1
View File
@@ -926,13 +926,45 @@ func brokerReachableAt(ctx context.Context, inv *inventory.Inventory, known brok
// A module that runs its machine's provisioner client and declares no broker is issued the client's account
// alone (issue 917): the client speaks on the bus only as itself.
func mayIssue(m catalogue.Manifest) error {
if _, reads := m.OwnSecrets["broker"]; !reads && !runsTheProvisionerClient(m) {
processes := m.ProcessBuses()
// A module whose own tools run in the machine's tool runner — mesh-mail's real shape — declares no
// `broker` of its own at all (novox/hq ADR 0436 §2): its process's account is a different account,
// delivered as its own secret below, so a process with a bus is reason enough to pass this check on
// its own, before the module's own `broker` is asked for.
if _, reads := m.OwnSecrets["broker"]; !reads && !runsTheProvisionerClient(m) && len(processes) == 0 {
return fmt.Errorf("%s declares no own secret named broker, so there is nowhere to deliver "+
"an account; a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}", m.Module)
}
// And every process with a bus declaration (novox/hq ADR 0436 §2): its own account is delivered as the
// module's own secret broker-<process id>, never the module's `broker`, so a process with a bus and no
// such secret has nowhere to read an account either.
for id := range processes {
name := processSecretName(id)
if _, reads := m.OwnSecrets[name]; !reads {
return fmt.Errorf("%s's process %s declares bus, and declares no own secret named %s, so there "+
"is nowhere to deliver its account; a process with a bus declares \"own-secrets\": "+
"{%q: <path>} (novox/hq ADR 0436 §2)", m.Module, id, name, name)
}
}
return nil
}
// processSecretName is the own secret a process's bus account is delivered in (novox/hq ADR 0436 §2):
// broker-<process id>, never the module's own `broker`.
func processSecretName(processID string) string { return "broker-" + processID }
// processPrincipalFor is the one process of m's bus declarations named processID, as `rollout mint` finds it
// again from a KindProcess principal's bare id (novox/hq ADR 0436 §1, §2): false when the manifest no longer
// declares it.
func processPrincipalFor(m catalogue.Manifest, processID string) (broker.ProcessPrincipal, bool) {
for _, pr := range inventory.ProcessesOf(m) {
if pr.Process == processID {
return pr, true
}
}
return broker.ProcessPrincipal{}, false
}
// issueOnTheNewBus gives an assigned module its credential on the bus being built.
//
// **Three things differ from a management call, and each is the point of the move.** The credential
@@ -968,12 +1000,82 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
return err
}
}
// Every process with a bus declaration gets an account of its own (novox/hq ADR 0436 §1, §2), minted and
// delivered whether or not the module itself speaks on the bus — its own secret is broker-<process id>,
// never the module's `broker`.
for _, pr := range inventory.ProcessesOf(m) {
if err := issueProcess(ctx, inv, m, pr, node, busAddress, known, reachable); err != nil {
return err
}
}
if !runsTheProvisionerClient(m) {
return nil
}
return issueProvisionerClient(ctx, inv, m, node, known, reachable)
}
// issueProcess gives one process its account: minted under its own name <node>.<module>.<process id>,
// delivered as the module's own secret broker-<process id> in the provisioner client's form (novox/hq ADR
// 0436 §2), handed to the vault as any covered account is, and its work-queue consumer or consumers
// created where the bus can be reached — never the module's claims, since a process holds no seat.
func issueProcess(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest, pr broker.ProcessPrincipal,
node, busAddress string, known broker.Broker, reachable string) error {
user := broker.Principal{Kind: broker.KindProcess, Node: node, Module: m.Module, Process: pr.Process}.Username()
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
Username: user, Kind: inventory.BusProcess, Node: node, Module: m.Module,
})
if err != nil {
return err
}
name := processSecretName(pr.Process)
held, err := heldProcessAccount(known, reachable, node, m.Module, user, password)
if err != nil {
return err
}
if err := inv.AcceptSecretForModule(ctx, node, m.Module, name, string(held)); err != nil {
return err
}
if err := handIssuedToVault(ctx, inv, m, node, name, string(held)); err != nil {
return err
}
if len(pr.Queues) > 0 {
if busAddress == "" {
fmt.Printf(" %s's process %s consumes its work queue; its consumer is created when the bus is "+
"reachable (`push --behind`, then `rollout mint` again is harmless)\n", m.Module, pr.Process)
} else {
js, err := broker.Dial(busAddress)
if err != nil {
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
"%s's process %s's work-queue consumer: %w", m.Module, pr.Process, err)
}
defer js.Close()
who := broker.Principal{Kind: broker.KindProcess, Node: node, Module: m.Module, Process: pr.Process}
for _, q := range pr.Queues {
if err := js.EnsureConsumer(broker.QueueConsumer(who, q)); err != nil {
return err
}
}
}
}
fmt.Printf("bus user %s minted for %s's process %s, scoped to its bus declaration\n", user, m.Module, pr.Process)
fmt.Printf(" sealed to %s as %s. It arrives with the next push — `push %s` to send it\n", node, name, node)
return nil
}
// heldProcessAccount is a process's account, in the provisioner client's form (novox/hq ADR 0436 §2):
// {url, fingerprint, node, module, user, password} — never claims, since a process holds no seat (§3).
func heldProcessAccount(known broker.Broker, reachable, node, module, user, password string) ([]byte, error) {
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
}{URL: "nats://" + reachable, Fingerprint: known.Fingerprint, Node: node, Module: module, User: user,
Password: password})
}
// runsTheProvisionerClient says a module runs its machine's provisioner client, which reaches the bus on an
// account of its own (novox/hq ADR 0349 clause 11, design 57 P3): it claims the seat and declares the own secret
// that account is delivered in. The same test the bus's user list makes (inventory's declaredFor).
@@ -0,0 +1,87 @@
//go:build integration
package main
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/broker"
)
// review round 3, fault B (1181): the test asserted a literal url, while issueOnTheNewBus works out the
// machine's reachable address from the catalogue's network records (brokerReachableAt), not from this
// process's environment — the real try found "nats://anchor.internal:5671", not the literal guessed here.
// Fixed by calling issueProcess directly, with the known broker and reachable address given to it, exactly
// as issued_to_vault_test.go's issue/issueWith and issueProvisionerClient are called directly; the url
// asserted below is one of those two inputs, never a separate guess.
//
// review round 1, findings 3 and 4 (1181/AC1, AC2): module issue mints and delivers a process's own
// account against the store — no bus to dial, busAddress "" — for mesh-mail's real manifest shape (review
// round 1 finding 1: no own secret `broker` at all, its tools served by the tool runner), and issuing it
// again is the path every bus account takes (`rollout mint`'s KindProcess case, and a re-run of `module
// issue`): it rotates the password, and the old hash no longer matches.
func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
theVaultIsInstalled(t, open)
m := mailSenderWithProcess()
m.Moved = true // so handIssuedToVault's accountIsCovered is true, and the hand-off is exercised whole
register(t, open, m)
vault := newStandInVault(t)
vaultAskerForIssue = func(context.Context) vaultAsker { return vault.ask }
t.Cleanup(func() { vaultAskerForIssue = func(context.Context) vaultAsker { return newStandInVault(nil).ask } })
pr, found := processPrincipalFor(m, "send")
if !found {
t.Fatal("mesh-mail declares no process send")
}
known := broker.Broker{Fingerprint: "ab"}
const reachable = "10.0.0.1:4222"
issue := func() {
t.Helper()
if err := issueProcess(ctx, inv, m, pr, "laptop", "", known, reachable); err != nil {
t.Fatal(err)
}
}
issue()
user := broker.Principal{Kind: broker.KindProcess, Node: "laptop", Module: "mesh-mail", Process: "send"}.Username()
if user != "laptop.mesh-mail.send" {
t.Fatalf("the process's username: %q", user)
}
hash1, held, err := inv.BusUserHash(ctx, user)
if err != nil || !held || hash1 == "" {
t.Fatalf("the process's account was not minted: held %v, %v", held, err)
}
// 1181/AC1: accepted with the provisioner client's JSON, and handed to the vault whole — the url and
// the fingerprint being exactly what issueProcess was given above, not a guess at what it computes.
taken := vault.taken["accept"]
for _, want := range []string{`"url":"nats://` + reachable + `"`, `"fingerprint":"ab"`, `"node":"laptop"`,
`"module":"mesh-mail"`, `"user":"laptop.mesh-mail.send"`, `"password"`} {
if !strings.Contains(taken, want) {
t.Errorf("the vault was handed %q, without %s", taken, want)
}
}
if !strings.Contains(vault.said(), `"name":"broker-send"`) {
t.Errorf("the vault was not told the secret's name broker-send: %s", vault.said())
}
// The file's own owner (mesh-mail-send, review round 1 finding 7's area) is 1170's mechanism, proven
// generically there and for this fixture in TestAProcessesOwnSecretIsOwnedByItsOwnAccount.
// 1181/AC2: issued again — the path `rollout mint`'s KindProcess case and a re-run of `module issue`
// both take — rotates the password; the old hash no longer matches what is recorded.
issue()
hash2, held, err := inv.BusUserHash(ctx, user)
if err != nil || !held {
t.Fatalf("the process's account is gone after a re-issue: held %v, %v", held, err)
}
if hash1 == hash2 {
t.Fatal("issuing the process's account again did not rotate its password")
}
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
)
// mailSenderWithProcess is mesh-mail's real manifest shape (novox/hq ADR 0436 §7, §8, the lead's fixture):
// no own secret named `broker` at all — its tools run in the machine's tool runner — and the process "send"
// declares bus, whose own secret broker-send is where module issue delivers its account (ADR 0436 §2),
// owned by mesh-mail-send, the user resource the process declares, at mode 0600 as every own secret is.
func mailSenderWithProcess() catalogue.Manifest {
m, err := catalogue.ParseManifest([]byte(`{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],
"own-secrets":{"broker-send":{"path":"/var/lib/mesh-mail/broker-send","owner":"mesh-mail-send"}},
"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},
{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send",
"bus":{"emits":["mail.send.sent"]}}]}`))
if err != nil {
panic(err)
}
return m
}
// review round 1, finding 1 (1181): a module declaring no `broker` of its own at all — mesh-mail's real
// shape, tools served by the tool runner — still passes mayIssue on its process's bus declaration alone.
func TestAModuleWithNoBrokerAndOnlyAProcessMayBeIssued(t *testing.T) {
if err := mayIssue(mailSenderWithProcess()); err != nil {
t.Fatalf("a module with no broker of its own, whose process declares bus, was refused: %v", err)
}
}
// 1181/AC1: a process with a bus declaration and no own secret broker-<process id> cannot be issued — the
// same refusal mayIssue already gives a module with no `broker` (novox/hq ADR 0436 §2).
func TestAProcessWithNoBrokerSecretCannotBeIssued(t *testing.T) {
m := mailSenderWithProcess()
delete(m.OwnSecrets, "broker-send")
err := mayIssue(m)
if err == nil {
t.Fatal("a process with no broker-<id> own secret was issued an account")
}
for _, want := range []string{"mesh-mail", "send", "broker-send", "own-secrets"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q: %v", want, err)
}
}
if err := mayIssue(mailSenderWithProcess()); err != nil {
t.Errorf("a process declaring its own broker-<id> secret was refused: %v", err)
}
}
// 1181/AC1: the account held for a process is the provisioner client's form — {url, fingerprint, node,
// module, user, password} — named for the process, never the module's own account.
func TestAProcesssHeldAccountIsTheProvisionersForm(t *testing.T) {
user := broker.Principal{Kind: broker.KindProcess, Node: "novox", Module: "mesh-mail", Process: "send"}.Username()
if user != "novox.mesh-mail.send" {
t.Fatalf("the process's username: %q", user)
}
held, err := heldProcessAccount(broker.Broker{Fingerprint: "abc"}, "novox.example:4222", "novox", "mesh-mail",
user, "a-password")
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(held, &got); err != nil {
t.Fatal(err)
}
want := map[string]any{"url": "nats://novox.example:4222", "fingerprint": "abc", "node": "novox",
"module": "mesh-mail", "user": "novox.mesh-mail.send", "password": "a-password"}
for k, v := range want {
if got[k] != v {
t.Errorf("%s: %v, want %v", k, got[k], v)
}
}
if len(got) != len(want) {
t.Errorf("the held account carries %v; want exactly %v (never claims — a process holds no seat)", got, want)
}
}
// 1181/AC1: composed, the process's own secret belongs to its own account at 0600 (novox/hq ADR 0436 §1, §2;
// the owner mechanism is issue 1170's, proven generically there — this is mesh-mail's own shape).
func TestAProcessesOwnSecretIsOwnedByItsOwnAccount(t *testing.T) {
m := mailSenderWithProcess()
r := catalogue.Resolution{Node: "novox", Modules: []catalogue.Manifest{m}}
out, err := r.Declaration(catalogue.Rendering{
Needed: map[string]map[string]string{"mesh-mail": {"broker-send": "sealed-account"}},
})
if err != nil {
t.Fatalf("composing the declaration: %v", err)
}
var found bool
for _, res := range out {
if res["type"] == "file" && res["path"] == "/var/lib/mesh-mail/broker-send" {
found = true
if got := res["owner"]; got != "mesh-mail-send" {
t.Fatalf("broker-send's owner: %v; want mesh-mail-send", got)
}
}
}
if !found {
t.Fatal("broker-send was not composed at all")
}
}
+302
View File
@@ -0,0 +1,302 @@
//go:build integration
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// mailSenderLiveManifest is mesh-mail's future manifest shape whole (novox/hq ADR 0436 §7, §8): the
// process "send" declares every clause of bus (emits, its one work-queue stream, its state, asks, and its
// invoked seat verb), so the account module issue delivers for it can be put through every grant at once.
func mailSenderLiveManifest(t *testing.T) catalogue.Manifest {
t.Helper()
m, err := catalogue.ParseManifest([]byte(`{"module":"mesh-mail","version":"1",
"emits":["mail.send.sent","mail.send.failed"],"state":["sends"],
"streams":[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,"max-mib":8,
"retention":"work-queue","max-deliveries":5}],
"own-secrets":{"broker-send":{"path":"/var/lib/mesh-mail/broker-send","owner":"mesh-mail-send"}},
"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},
{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send",
"bus":{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],"state":["sends"],
"asks":true,"invokes":["seat:operator-channel.tell"]}}]}`))
if err != nil {
t.Fatal(err)
}
return m
}
// operatorChannelWithTell is operator-channel as messenger declares it (broker.operatorChannel, novox/hq
// ADR 0259 §3), with the one verb this release adds: `tell` (novox/hq ADR 0436 §1's own fixture), so the
// process's invoked seat verb has a holder to answer it.
func operatorChannelWithTell() broker.Seat {
return broker.Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"},
Emits: []string{"decided"}, Serves: []string{"open", "history", "notify", "tell"},
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
}
// review round 1, finding 5 (1181/AC3), the release's acceptance test, on a real bus and the store: the
// account module issue delivers for mesh-mail's process "send" — composed from the manifest the real way
// (inventory.ProcessesOf -> broker.Users -> broker.ComposeAccounts), minted with the password module issue
// mints (aMesh) — does exactly what its bus declares and nothing else.
func TestAProcessAccountOnARealBusAndStore(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
m := mailSenderLiveManifest(t)
operatorChannel := operatorChannelWithTell()
mailSends := broker.ModuleStream{Module: "mesh-mail", Name: "MAIL_SENDS", Events: []string{"mail.send.approved"},
MaxAgeDays: 1, MaxMiB: 8, WorkQueue: true, Duplicates: 1}
records := broker.Records{Nodes: []string{"laptop"}, Assigned: map[string][]broker.Declared{"laptop": {
{Module: broker.RuntimeModule},
{Module: "messenger", Holds: []broker.Seat{operatorChannel}, RunsAs: "messenger"},
{Module: "mesh-mail", NoAccount: true, Uses: []broker.Seat{operatorChannel}, Streams: []broker.ModuleStream{mailSends},
Processes: inventory.ProcessesOf(m)},
}}}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
// The four principals this test drives, each minted the password module issue mints — the same
// inv.MintBusPassword call issueProcess makes for the process account. mesh-mail publishes its own
// stream's events as the tool runner carrying it (review round 2's finding): the controller's own
// grants do not cover a module's stream, only the runtime that carries the module does.
var admin, runtime, messenger, sender broker.Principal
for _, p := range users {
switch {
case p.Kind == broker.KindController:
admin = p
case p.Kind == broker.KindNodeTools:
runtime = p
case p.Module == "messenger":
messenger = p
case p.Kind == broker.KindProcess:
sender = p
}
}
if admin.Kind == "" || runtime.Kind == "" || messenger.Kind == "" || sender.Kind == "" {
t.Fatalf("not every principal was composed: admin=%+v runtime=%+v messenger=%+v sender=%+v",
admin, runtime, messenger, sender)
}
if sender.Username() != "laptop.mesh-mail.send" {
t.Fatalf("the process's username: %q", sender.Username())
}
passwords := map[string]string{}
hashes := map[string]string{}
for _, p := range []struct {
principal broker.Principal
kind string
}{{admin, inventory.BusController}, {runtime, inventory.BusNodeTools}, {messenger, inventory.BusModule},
{sender, inventory.BusProcess}} {
pw, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.principal.Username(), Kind: p.kind,
Node: p.principal.Node, Module: p.principal.Module})
if err != nil {
t.Fatal(err)
}
passwords[p.principal.Username()] = pw
hash, held, err := inv.BusUserHash(ctx, p.principal.Username())
if err != nil || !held {
t.Fatalf("the minted password of %s was not recorded: %v, %v", p.principal.Username(), held, err)
}
hashes[p.principal.Username()] = hash
}
filled, missing := broker.WithPasswords([]broker.Principal{admin, runtime, messenger, sender}, hashes)
if len(missing) > 0 {
t.Fatalf("no password for %v", missing)
}
accounts, err := broker.ComposeAccounts(filled)
if err != nil {
t.Fatal(err)
}
conf := filepath.Join(t.TempDir(), "bus.conf")
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatalf("the composed accounts do not parse: %v", err)
}
opts.NoLog, opts.NoSigs = true, true
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
defer s.Shutdown()
// Every connection names its own inbox prefix: the grant is `_INBOX.<username>.>`, and the client
// library's own default random inbox matches nothing granted, so replies are refused without it.
adminConn, err := broker.Dial(s.ClientURL(), nats.UserInfo(admin.Username(), passwords[admin.Username()]),
nats.CustomInboxPrefix("_INBOX."+admin.Username()))
if err != nil {
t.Fatal(err)
}
defer adminConn.Close()
if err := adminConn.EnsureStream(mailSends.Stream()); err != nil {
t.Fatal(err)
}
if err := adminConn.EnsureConsumer(broker.QueueConsumer(sender, sender.Queues[0])); err != nil {
t.Fatal(err)
}
if err := adminConn.EnsureBucket(broker.Bucket{Module: "mesh-mail", Name: "sends"}); err != nil {
t.Fatal(err)
}
js, err := jetstream.New(adminConn.Conn())
if err != nil {
t.Fatal(err)
}
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "messenger_asks"}); err != nil {
t.Fatal(err)
}
// The machine's tool runner, which carries mesh-mail (it has no account of its own) and so is the
// principal that publishes its own stream's events on its behalf.
runtimeConn, err := nats.Connect(s.ClientURL(), nats.UserInfo(runtime.Username(), passwords[runtime.Username()]),
nats.CustomInboxPrefix("_INBOX."+runtime.Username()))
if err != nil {
t.Fatal(err)
}
defer runtimeConn.Close()
// messenger, the router, answers exactly its granted verbs — never a wildcard the grant does not give it.
holder, err := nats.Connect(s.ClientURL(), nats.UserInfo(messenger.Username(), passwords[messenger.Username()]),
nats.CustomInboxPrefix("_INBOX."+messenger.Username()))
if err != nil {
t.Fatal(err)
}
defer holder.Close()
heard := make(chan string, 1)
for _, verb := range []string{"open", "history", "notify", "tell"} {
if _, err := holder.Subscribe("mesh.seat.operator-channel.tool."+verb, func(m *nats.Msg) {
heard <- m.Subject
_ = m.Respond([]byte(`{"result":{}}`))
}); err != nil {
t.Fatal(err)
}
}
_ = holder.Flush()
refusals := make(chan error, 8)
sender2, err := nats.Connect(s.ClientURL(), nats.UserInfo(sender.Username(), passwords[sender.Username()]),
nats.CustomInboxPrefix("_INBOX."+sender.Username()),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
if err != nil {
t.Fatal(err)
}
defer sender2.Close()
// 1. Its event.
if err := sender2.Publish("mesh.mod.mesh-mail.event.mail.send.sent", []byte("{}")); err != nil {
t.Fatal(err)
}
// 2. Its state: writes and reads its own bucket.
senderKV, err := jetstream.New(sender2)
if err != nil {
t.Fatal(err)
}
bucket, err := senderKV.KeyValue(ctx, "mesh-mail_sends")
if err != nil {
t.Fatal(err)
}
if _, err := bucket.Put(ctx, "key", []byte("value")); err != nil {
t.Fatalf("the process may not write its own state: %v", err)
}
if got, err := bucket.Get(ctx, "key"); err != nil || string(got.Value()) != "value" {
t.Fatalf("the process may not read its own state back: %v, %v", got, err)
}
// 3. Its queue: pulls and acknowledges MAIL_SENDS through its own consumer. Published as the tool
// runner carrying mesh-mail — the live path a module's own stream's event takes — never as the
// controller, whose own grants do not cover a module's stream at all.
runtimeJS, err := jetstream.New(runtimeConn)
if err != nil {
t.Fatal(err)
}
if _, err := runtimeJS.Publish(ctx, broker.ModuleStreamSubject("mesh-mail", "mail.send.approved"), []byte("{}")); err != nil {
t.Fatal(err)
}
reading, err := jetstream.New(sender2)
if err != nil {
t.Fatal(err)
}
consumer, err := reading.Consumer(ctx, "MAIL_SENDS", broker.QueueConsumer(sender, sender.Queues[0]).Name)
if err != nil {
t.Fatalf("the process may not bind its own queue consumer: %v", err)
}
batch, err := consumer.Fetch(1, jetstream.FetchMaxWait(3*time.Second))
if err != nil {
t.Fatal(err)
}
got := false
for msg := range batch.Messages() {
got = true
if err := msg.Ack(); err != nil {
t.Fatalf("the process may not acknowledge its own queue: %v", err)
}
}
if !got {
t.Fatal("the process did not pull the message its own queue holds")
}
// 4. Its invoked seat verb, in its own name.
if _, err := sender2.Request("mesh.seat.operator-channel.tool.tell", []byte(`{}`), 3*time.Second); err != nil {
t.Fatalf("the process may not call operator-channel.tell: %v", err)
}
select {
case s := <-heard:
if s != "mesh.seat.operator-channel.tool.tell" {
t.Fatalf("messenger heard %s", s)
}
case <-time.After(time.Second):
t.Fatal("messenger never heard the call")
}
// 5. Refused, one at a time, so each refusal is counted against the attempt that caused it rather than
// any other: answering a tool call it holds no seat and serves no tool for.
if _, err := sender2.Subscribe("mesh.mod.mesh-mail.tool.>", func(*nats.Msg) {}); err != nil {
t.Fatal(err)
}
_ = sender2.Flush()
select {
case err := <-refusals:
if !errors.Is(err, nats.ErrPermissionViolation) {
t.Errorf("subscribing a tool call was not refused as a permissions violation: %v", err)
}
case <-time.After(3 * time.Second):
t.Error("subscribing a tool call the process does not serve was not refused")
}
// 6. Refused: publishing a warrant — only the holder, messenger, may say operator-channel decided.
if err := sender2.Publish("mesh.seat.operator-channel.event.decided.mesh-mail", []byte("{}")); err != nil {
t.Fatal(err)
}
_ = sender2.Flush()
select {
case err := <-refusals:
if !errors.Is(err, nats.ErrPermissionViolation) {
t.Errorf("publishing a warrant was not refused as a permissions violation: %v", err)
}
case <-time.After(3 * time.Second):
t.Error("publishing a warrant the process does not hold the seat for was not refused")
}
}
+26
View File
@@ -392,6 +392,32 @@ func rolloutMint(ctx context.Context, again bool) error {
}
modules++
case broker.KindProcess:
// One process's own account (novox/hq ADR 0436 §1, §2), delivered as the module's own secret
// broker-<process id> — minted here exactly as `module issue` mints it, so a process issued
// once still gets a new password every time the bus's accounts are issued again.
m, inShelf := shelf[p.Module]
if !inShelf {
skipped++
continue
}
pr, found := processPrincipalFor(m, p.Process)
if !found {
skipped++
continue
}
name := processSecretName(p.Process)
if _, reads := m.OwnSecrets[name]; !reads {
fmt.Printf(" %s's process %s declares bus but declares no `%s` secret to receive a "+
"credential in; skipped\n", p.Module, p.Process, name)
skipped++
continue
}
if err := issueProcess(ctx, inv, m, pr, p.Node, "", known, busAddress); err != nil {
return err
}
modules++
default:
skipped++
}