diff --git a/internal/broker/jetstream.go b/internal/broker/jetstream.go index 5ae5c51..1ee9ceb 100644 --- a/internal/broker/jetstream.go +++ b/internal/broker/jetstream.go @@ -42,6 +42,12 @@ func Dial(url string, opts ...nats.Option) (*JetStream, error) { } opts = append(opts, nats.Secure(pinned)) } + // **Its own inbox, and nothing wider.** Every principal is granted `_INBOX..>` and + // no other inbox; the client's default prefix is random, and the server refused the first + // subscription to it (2026-09-28). The user is in the URL, so the prefix follows from it. + if user, _, _ := CredentialIn(url); user != "" { + opts = append(opts, nats.CustomInboxPrefix("_INBOX."+user)) + } // The address in an error is the address alone. The URL carries this controller's password, // and an error here is written on the assumption it will be logged. where := BareAddress(url) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 00830c7..562c376 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -521,7 +521,10 @@ func ComposeAccounts(principals []Principal) (string, error) { // One account for the mesh: accounts in NATS isolate subject spaces entirely, and the mesh is // one space (design 25 ยง4). The cost of that โ€” that permissions are the only isolation โ€” is // paid in the scoping of every inbox and every ack subject. - b.WriteString("accounts {\n MESH {\n users = [\n") + // JetStream is enabled per account once accounts exist at all: with only the global block set, + // a user in MESH is told "JetStream not enabled for account" the first time it binds a + // consumer, which is the first thing every host does (2026-09-28). + b.WriteString("accounts {\n MESH {\n jetstream: enabled\n users = [\n") for _, p := range sorted { perms, err := PermissionsFor(p) if err != nil { diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index d764044..6e6eb35 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -21,6 +21,7 @@ jetstream { accounts { MESH { + jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>"] } diff --git a/internal/broker/users_test.go b/internal/broker/users_test.go index 0e21eaf..83e8d86 100644 --- a/internal/broker/users_test.go +++ b/internal/broker/users_test.go @@ -186,7 +186,13 @@ func TestWhatTheMeshWritesIsUsersAndNothingAboutTheServer(t *testing.T) { } // None of the server's own settings. Each of these in the mesh's file is a value the controller // would then own, and the module could no longer change its own image without the mesh agreeing. - for _, absent := range []string{"port:", "http:", "jetstream", "tls {", "store_dir", "cert_file"} { + // `jetstream {` is the server's block (its store, its limits); `jetstream: enabled` inside the + // account is the account's, and the mesh owns the account โ€” a user in it is told "JetStream + // not enabled for account" without it (2026-09-28). + if !strings.Contains(got, "jetstream: enabled") { + t.Errorf("the account does not enable JetStream, so no user in it can bind a consumer") + } + for _, absent := range []string{"port:", "http:", "jetstream {", "tls {", "store_dir", "cert_file"} { if strings.Contains(got, absent) { t.Errorf("the accounts file contains %q, which belongs to the module that raises the "+ "server, not to the mesh", absent)