Compose the bus's accounts instead of calling a management API

Task 1.3 of novox/hq ADR 0116. On AMQP an account was an HTTP call; on NATS
it is text the controller composes and the server reloads (ADR 0106). Pure,
so the mesh's whole authority model is testable as strings.

NATS closes a gap management.go recorded rather than hid: LavinMQ has no
topic permissions, so an emitter was granted the events exchange whole and
ADR 0042's origin reservation was "stamped by the sdk, not enforced here".
Per-subject permissions make it the server's refusal.

Two things found by composing a real file rather than reading the design:

- a scoped inbox leaves a responder unable to reply, because the answer goes
  to the caller's inbox. allow_responses is the answer — one reply to the
  subject of a message actually received — and only principals that serve
  are granted it. Recorded in design 25 §4.
- composition must be deterministic: the module's entrypoint reloads on the
  file's digest, so an order-dependent composer would reload the whole bus
  on every controller restart. Covered by a test.

The golden fixture is the exact text `nats-server -t` accepts, so the syntax
is the server's rather than one we invented.
This commit is contained in:
2026-09-26 20:58:49 +02:00
parent fb87f9f7d6
commit c753f9d5c0
4 changed files with 571 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
// Composing the bus's own configuration.
//
// On AMQP an account was made by calling the broker's management API (management.go). On NATS it
// is *composed*: the controller writes accounts, users and per-subject permissions into one file
// the host keeps current, and the server reloads it in place (novox/hq ADR 0106 — never through a
// management API; design 25 §4).
//
// Everything here is pure. Given the principals, it returns the file's text — so the whole of the
// mesh's authority model is testable as strings, with no server, which is what management.go's
// `modulePermissions` already did for the half of it that could be.
//
// **NATS closes a gap AMQP left open.** management.go records it plainly: LavinMQ has no topic
// permissions, so an emitting module is granted the events exchange whole, and ADR 0042's origin
// reservation — a module publishes only under its own name — is "stamped by the sdk, not enforced
// here". NATS permissions are per subject, so that reservation becomes something the server
// refuses rather than something a library promises.
package broker
import (
"fmt"
"regexp"
"sort"
"strings"
)
// A Kind is what a principal is, which decides the shape of its authority rather than its
// contents: a module's comes from its declaration, a host's from its node, and the controller's
// and the enrolment user's are fixed.
type Kind string
const (
KindModule Kind = "module"
KindNode Kind = "node"
KindController Kind = "controller"
KindEnrolment Kind = "enrolment"
)
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
// emits (novox/hq ADR 0118, design 29 §5).
type Seat struct {
Name string
Accepts []string
Emits []string
Versions []string // protocol versions served beside the current one; empty for v1 only
}
// A Principal is one user of the bus. Its permissions are derived from what it declares and
// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats
// it holds, and the seats it uses.
type Principal struct {
Kind Kind
Node string
Module string
Emits []string
Consumes []string
Serves []string
Holds []Seat
Uses []Seat
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
// and never appears here: this file is written to a node's disk and read by a server, and a
// secret that can be read from a configuration file is a secret with a wider blast radius
// than the one it protects (novox/hq design 29 §10).
PasswordHash string
}
// safeSubject refuses anything that would change the meaning of a subject rather than sit inside
// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying
// `>` or `*` would widen it to a wildcard, which is the whole authority model gone.
var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Username is how a principal is named to the server. The node is part of it, so the same module
// on two machines holds two users, each sealed to its own — the rule management.go already
// applies, kept.
func (p Principal) Username() string {
switch p.Kind {
case KindModule:
return p.Node + "." + p.Module
case KindNode:
return "node." + p.Node
case KindController:
return "controller"
case KindEnrolment:
return "enrolment"
}
return ""
}
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other.
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
// Permissions is what a principal may publish and subscribe, and whether it may answer.
type Permissions struct {
Publish []string
Subscribe []string
// AllowResponses lets a principal reply to a request it received, on the reply subject that
// request carried, once.
//
// **This is what makes scoped inboxes possible at all**, and design 25 §4 did not say it. If
// every user's inbox is private to it, a module serving a tool cannot publish the answer —
// the answer goes to the *caller's* inbox, which the responder has no permission for. The two
// ways out are granting responders `_INBOX.>`, which is precisely the blanket grant §4
// refuses, or this: the server itself permits one reply to the subject of a message the user
// actually received, and nothing else. The authority is bounded by having been asked.
AllowResponses bool
}
// PermissionsFor derives a principal's authority. Pure, and the only place authority is decided:
// a permission that cannot be derived from a declaration is a permission nobody can explain.
func PermissionsFor(p Principal) (Permissions, error) {
for _, part := range []struct{ what, value string }{
{"node", p.Node}, {"module", p.Module},
} {
if part.value == "" {
continue
}
if !safeSubject.MatchString(part.value) {
return Permissions{}, fmt.Errorf(
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", part.value)
}
}
var pub, sub []string
switch p.Kind {
case KindController:
// The controller owns the mesh's own traffic and the streams. It is the only writer of
// stream definitions (design 25 §3), so it alone reaches the JetStream API.
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.build.>", "$JS.API.>"}
sub = []string{"mesh.control.>", "mesh.build.>", "$JS.API.>"}
case KindEnrolment:
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
// an event, or subscribe any inbox but the one its own token derives (design 25 §6).
pub = []string{"mesh.control.enrol"}
sub = []string{}
case KindNode:
// A host publishes its own node's control traffic and subscribes its own declaration —
// and nothing of any other node's.
pub = []string{"mesh.control." + p.Node + ".>"}
sub = []string{"mesh.node." + p.Node + ".declare"}
case KindModule:
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
// else may publish into it, so an event's source is a fact the server enforces rather
// than a claim in the body (design 29 §2).
own := "mesh.mod." + p.Module
if len(p.Emits) > 0 {
for _, e := range p.Emits {
pub = append(pub, own+"."+e)
}
}
for _, t := range p.Serves {
sub = append(sub, own+".tool."+t)
}
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
// emitter, because the emitter's identity is the meaning (ADR 0118).
for _, c := range p.Consumes {
sub = append(sub, "mesh.mod."+c)
}
// 3. Seats it holds: full participation.
for _, s := range p.Holds {
for _, a := range s.Accepts {
sub = append(sub, seatSubject(s, a))
}
for _, e := range s.Emits {
pub = append(pub, seatSubject(s, e))
}
}
// 4. Seats it uses: publish only, and only the accepts half. A caller cannot subscribe a
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
// events and lie about outcomes (design 29 §2).
for _, s := range p.Uses {
for _, a := range s.Accepts {
pub = append(pub, seatSubject(s, a))
}
}
}
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
// Its own reply space, and nothing wider.
sub = append(sub, p.inbox())
// Acking a JetStream delivery is a publish to that consumer's own ack address — a
// different subject from anything the consumer subscribes. Without it every message a
// module received would be redelivered forever, refused by the permission list it already
// has (design 25 §4). Scoped to this principal's own consumer name, so it can ack its own
// deliveries and no other's.
pub = append(pub, "$JS.ACK."+consumerName(p)+".>")
}
sort.Strings(pub)
sort.Strings(sub)
return Permissions{
Publish: pub,
Subscribe: sub,
// Only something that serves is ever answering. A pure consumer is granted nothing here.
AllowResponses: p.Kind == KindModule && (len(p.Serves) > 0 || len(p.Holds) > 0) ||
p.Kind == KindController,
}, nil
}
// seatSubject places a seat's verb. A seat serving more than its current protocol version carries
// the version as a token (design 29 §8): the seat stays one role, and v1 and v2 run beside each
// other until nothing is bound to the old one.
func seatSubject(s Seat, verb string) string {
return "mesh.seat." + s.Name + "." + verb
}
// consumerName is the durable consumer the controller derives for this principal. It is here
// rather than in the caller because the permission and the consumer must agree by construction —
// two places deriving the same name is how a module ends up unable to ack its own deliveries.
func consumerName(p Principal) string {
switch p.Kind {
case KindModule:
return "EVENTS." + p.Node + "_" + p.Module
case KindNode:
return "NODES." + p.Node
case KindController:
return "CONTROL.controller"
}
return ""
}
// Server is everything the composed file needs that is not a principal.
type Server struct {
// ClientPort carries TLS itself; there is no plaintext port beside it, which is where this
// differs from the AMQP broker's 5671/5672 pair.
ClientPort int
MonitoringPort int
TLSCert string
TLSKey string
TLSCA string
// StoreDir is a host directory bind, not a named volume — issue 115 is resolved and converted
// four modules away from named volumes; the bus's own data is not the place to bring one back.
StoreDir string
}
// Compose renders the server's whole configuration. The order is stable and the output is
// deterministic, because the file's digest is what the module's entrypoint watches to decide
// whether to reload: a composer that reordered a map on each run would signal a reload every time
// the controller restarted, for a file that had not changed.
func Compose(s Server, principals []Principal) (string, error) {
sorted := append([]Principal(nil), principals...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() })
var b strings.Builder
b.WriteString("# Composed by the mesh controller. Do not edit: the next composition overwrites it.\n")
b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n")
b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n")
fmt.Fprintf(&b, "port: %d\n", s.ClientPort)
fmt.Fprintf(&b, "http: 127.0.0.1:%d\n\n", s.MonitoringPort)
b.WriteString("tls {\n")
fmt.Fprintf(&b, " cert_file: %q\n", s.TLSCert)
fmt.Fprintf(&b, " key_file: %q\n", s.TLSKey)
fmt.Fprintf(&b, " ca_file: %q\n", s.TLSCA)
b.WriteString(" verify: true\n")
b.WriteString("}\n\n")
b.WriteString("jetstream {\n")
fmt.Fprintf(&b, " store_dir: %q\n", s.StoreDir)
b.WriteString("}\n\n")
// 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 above, in the scoping of every inbox and every ack subject.
b.WriteString("accounts {\n MESH {\n users = [\n")
for _, p := range sorted {
perms, err := PermissionsFor(p)
if err != nil {
return "", err
}
if p.PasswordHash == "" {
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
}
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
if perms.AllowResponses {
b.WriteString(" allow_responses: { max: 1, ttl: \"1m\" }\n")
}
b.WriteString(" } }\n")
}
b.WriteString(" ]\n }\n}\n")
return b.String(), nil
}
func quoted(values []string) string {
if len(values) == 0 {
return ""
}
out := make([]string, len(values))
for i, v := range values {
out[i] = fmt.Sprintf("%q", v)
}
return strings.Join(out, ", ")
}
+49
View File
@@ -0,0 +1,49 @@
package broker
import (
"flag"
"os"
"path/filepath"
"testing"
)
var update = flag.Bool("update", false, "rewrite the golden composition")
// The composed file is the mesh's whole authority model, so a change to it should be visible in a
// review rather than inferred from a diff of Go. The fixture is also the exact text checked
// against the real server's parser (`nats-server -t`), which is what says this syntax is the
// server's and not one we invented.
func TestTheComposedConfigMatchesTheGolden(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
got, err := Compose(
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
[]Principal{
{Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"},
{Kind: KindEnrolment, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
{Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat},
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
{Kind: KindModule, Node: "two", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
})
if err != nil {
t.Fatal(err)
}
golden := filepath.Join("testdata", "composed.conf")
if *update {
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Errorf("composition changed; re-run with -update and read the diff:\n%s", got)
}
}
+166
View File
@@ -0,0 +1,166 @@
package broker
import (
"strings"
"testing"
)
func has(t *testing.T, subjects []string, want string) {
t.Helper()
for _, s := range subjects {
if s == want {
return
}
}
t.Fatalf("expected %q among %v", want, subjects)
}
func hasNot(t *testing.T, subjects []string, unwanted string) {
t.Helper()
for _, s := range subjects {
if s == unwanted {
t.Fatalf("did not expect %q among %v", unwanted, subjects)
}
}
}
// A module's authority comes from its declaration and nothing else (novox/hq ADR 0043).
func TestAModulePublishesOnlyWhatItEmits(t *testing.T) {
p := Principal{Kind: KindModule, Node: "one", Module: "billing",
Emits: []string{"order.placed"}, PasswordHash: "x"}
perms, err := PermissionsFor(p)
if err != nil {
t.Fatal(err)
}
has(t, perms.Publish, "mesh.mod.billing.order.placed")
hasNot(t, perms.Publish, "mesh.mod.billing.>")
hasNot(t, perms.Publish, "mesh.mod.shipping.order.placed")
}
// The gap AMQP left open — an emitter granted the events exchange whole — is closed by per-subject
// permissions. A module cannot publish under another module's name.
func TestAModuleCannotPublishUnderAnothersName(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
Emits: []string{"order.placed"}, PasswordHash: "x"})
for _, p := range perms.Publish {
if strings.HasPrefix(p, "mesh.mod.") && !strings.HasPrefix(p, "mesh.mod.billing.") {
t.Fatalf("billing may publish %q, which is not its own namespace", p)
}
}
}
// A caller of a seat may publish what the seat accepts, and nothing else of it: not its outbound
// events, and not a subscription to its inbound queue (design 29 §2).
func TestUsingASeatIsPublishOnlyAndInboundOnly(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
Uses: []Seat{seat}, PasswordHash: "x"})
has(t, perms.Publish, "mesh.seat.telegram-sender.send")
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.delivered")
hasNot(t, perms.Subscribe, "mesh.seat.telegram-sender.send")
}
// The holder is the mirror image: it consumes what the seat accepts and publishes what it emits.
func TestHoldingASeatIsTheMirrorOfUsingIt(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "telegram",
Holds: []Seat{seat}, PasswordHash: "x"})
has(t, perms.Subscribe, "mesh.seat.telegram-sender.send")
has(t, perms.Publish, "mesh.seat.telegram-sender.delivered")
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.send")
}
// Without an ack permission a durable consumer never really consumes: every message it receives is
// redelivered forever, refused by the permission list it already has (design 25 §4).
func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
has(t, perms.Publish, "$JS.ACK.EVENTS.one_billing.>")
hasNot(t, perms.Publish, "$JS.ACK.>")
hasNot(t, perms.Publish, "$JS.ACK.EVENTS.one_shop.>")
}
// With one account, inbox privacy is the permission list or it is nothing.
func TestAnInboxIsScopedToItsOwner(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing", PasswordHash: "x"})
has(t, perms.Subscribe, "_INBOX.one.billing.>")
hasNot(t, perms.Subscribe, "_INBOX.>")
hasNot(t, perms.Subscribe, "_INBOX.one.shop.>")
}
// A responder answers on the caller's inbox, which it has no permission for. allow_responses is
// what makes a scoped inbox workable at all — the authority is bounded by having been asked.
func TestOnlySomethingThatServesMayAnswer(t *testing.T) {
serving, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
Serves: []string{"status"}, PasswordHash: "x"})
if !serving.AllowResponses {
t.Fatal("a module serving a tool cannot answer the caller's inbox")
}
consumer, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
if consumer.AllowResponses {
t.Fatal("a pure consumer was granted the right to answer, which nothing asked it to do")
}
}
// A host reaches its own node's control traffic and its own declaration, and nothing of any
// other node's.
func TestAHostIsConfinedToItsOwnNode(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
has(t, perms.Publish, "mesh.control.one.>")
has(t, perms.Subscribe, "mesh.node.one.declare")
hasNot(t, perms.Subscribe, "mesh.node.two.declare")
hasNot(t, perms.Subscribe, "mesh.node.>")
}
// A leaked enrolment token is useless for anything but enrolling (design 25 §6).
func TestTheEnrolmentUserCanOnlyEnrol(t *testing.T) {
perms, _ := PermissionsFor(Principal{Kind: KindEnrolment, PasswordHash: "x"})
if len(perms.Publish) != 1 || perms.Publish[0] != "mesh.control.enrol" {
t.Fatalf("enrolment may publish %v", perms.Publish)
}
if len(perms.Subscribe) != 0 {
t.Fatalf("enrolment may subscribe %v, and should hear nothing", perms.Subscribe)
}
}
// A name that would widen a permission is refused rather than quietly stretching one.
func TestANameThatWouldWidenAPermissionIsRefused(t *testing.T) {
for _, bad := range []string{"bill.ing", "billing.>", "*", "bil>ling"} {
if _, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: bad, PasswordHash: "x"}); err == nil {
t.Fatalf("%q was accepted as part of a subject", bad)
}
}
}
// The entrypoint reloads on the file's digest changing, so an unchanged mesh must compose an
// identical file — otherwise every controller restart signals a reload of the whole bus.
func TestComposingTwiceGivesTheSameBytes(t *testing.T) {
s := Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}
ps := []Principal{
{Kind: KindModule, Node: "two", Module: "shop", Emits: []string{"order.placed"}, PasswordHash: "b"},
{Kind: KindController, PasswordHash: "c"},
{Kind: KindModule, Node: "one", Module: "billing", Consumes: []string{"shop.order.placed"}, PasswordHash: "a"},
}
first, err := Compose(s, ps)
if err != nil {
t.Fatal(err)
}
shuffled := []Principal{ps[2], ps[0], ps[1]}
second, err := Compose(s, shuffled)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatal("composition is order-dependent; every controller restart would reload the bus")
}
}
// A user without a password is a user anybody is.
func TestAUserWithoutAPasswordIsRefused(t *testing.T) {
_, err := Compose(Server{ClientPort: 4222}, []Principal{{Kind: KindController}})
if err == nil {
t.Fatal("composed a user with no password hash")
}
}
+50
View File
@@ -0,0 +1,50 @@
# Composed by the mesh controller. Do not edit: the next composition overwrites it.
# Accounts and permissions are derived from what each module declares and nothing
# else (novox/hq ADR 0043, design 29 §2).
port: 4222
http: 127.0.0.1:8222
tls {
cert_file: "/tls/tls.crt"
key_file: "/tls/tls.key"
ca_file: "/tls/ca.crt"
verify: true
}
jetstream {
store_dir: "/data"
}
accounts {
MESH {
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.API.>", "mesh.build.>", "mesh.control.>", "mesh.node.>"] }
subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.build.>", "mesh.control.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "enrolment", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
publish: { allow: ["mesh.control.enrol"] }
subscribe: { allow: [] }
} }
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
publish: { allow: ["$JS.ACK.NODES.one.>", "mesh.control.one.>"] }
subscribe: { allow: ["_INBOX.node.one.>", "mesh.node.one.declare"] }
} }
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "mesh.seat.telegram-sender.delivered", "mesh.seat.telegram-sender.failed"] }
subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.mod.telegram.tool.status", "mesh.seat.telegram-sender.send"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>"] }
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.mod.shop.order.placed"] }
} }
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "mesh.mod.shop.order.placed", "mesh.seat.telegram-sender.send"] }
subscribe: { allow: ["_INBOX.two.shop.>"] }
} }
]
}
}