// Package userlist is the bridge of novox/hq ADR 0547 §8: the node agent of the node holding mesh-broker writes the bus's // user list alone, with no want, so a new account is in force on the bus without anybody's hand. // // **Why it exists.** The list reached the bus only through a want of the bus module, and ADR 0539 §15 bars the assigner // from writing one: on 2026-10-25 it stood from 11:23 to 20:50 and every new account was refused until the operator put // it back by hand (issue 1945). The bus module's entrypoint already reloads the server in place when the file changes; // what was missing is a writer of the file that does not wait on a want. // // **The grants part's list alone** (ADR 0547 clauses 7, 8 and 11, ADR 0571 clause 8; issue 1126: the controller ended by // its unassignment and its list is written by nobody). It watches the grants part's one key `user-list.sealed`: the list // is the object the key names, read by its name, opened with this node's sealing key and checked against the key's // digest, and written alone (mode 0600, apply.WriteAlone) only when the file's digest differs. A key that cannot be read // or is deleted is said, the key last heard stands, and the list loaded stays; with no key ever heard nothing is // written. The list a composition carries is never written. // // **Where it writes.** The file is the user-list resource (`bus-users`) of the bus module's composition as this node runs // it (Env.Running: the agent's own record of what runs, its own composition or the declaration kept before the switch), // never one read from a composition bucket: the controller that wrote `composed-` ended (issue 1126), and the bus // module on the bus node runs from what the agent holds. That is what tells the bus node it is one, and the file's // place. The have of that module says what was written, which list it follows and this node's key, which the grants // part seals to. // // **It watches, it never polls**: the grants part's key is watched, and what runs is looked at again when the agent says // it changed, after every apply of the bus module and when asked (Again). It reads no want and writes none, moves no // fingerprint, and touches nothing of the bus module but its user list. package userlist import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "regexp" "sort" "strings" "sync" "time" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/seal" "github.com/novox/mesh-host/internal/agent" ) // ResourceID is the id the composer gives the bus's user list (mesh-sdk catalogue.BusUsersID); in a composition it may // carry its module before it, `.bus-users`. const ResourceID = "bus-users" // Written is what the follower last did: the source it took the list from, the list's digest there, the file's digest // on the machine after the write, how many users the list holds, when, and why a write failed. type Written = delivery.UserListWritten // Env is everything the follower needs of the node. type Env struct { Node string // Running is the compositions this node runs now, as its agent holds them (agent.Agent.Everything): where the user // list's file is is read from these alone (issue 1126). Nil runs nothing. Running func() []delivery.Composed // OnDisk is what the resource's file holds on the machine now; nil when there is none. OnDisk func(resource json.RawMessage) ([]byte, error) // Write writes the user list's file resource of a module's composition alone, with body as its content, when it // differs from the file on the machine, and answers the digest of the file there after it (`sha256:`) and // whether it changed. Write func(ctx context.Context, module string, resource json.RawMessage, body []byte) (digest string, changed bool, err error) // Report says what was written, in the module's have. Report func(ctx context.Context, module string, w Written) // Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil writes nothing. Grants agent.Watcher // Fetch reads an object of the grants part's bucket by its name; Open opens a box sealed to this node's key; Key is // this node's sealing key's public half (SPKI PEM), said in the have so the grants part seals to it ("" unknown). Fetch func(ctx context.Context, name string) ([]byte, error) Open func(seal.Box) ([]byte, error) Key func() string // Kept is the grants part's key as last heard on this machine and its revision (nil when none), and Keep keeps it: // what the follower follows while the key cannot be watched (ADR 0547 clause 8). Backoff is the wait before the // try-th watch again. Kept func() ([]byte, uint64) Keep func(value []byte, rev uint64) Backoff func(try int) time.Duration Now func() time.Time Say func(string) } type source struct { module string resource json.RawMessage } // Follower follows the grants part's user list into the file the bus module this node runs declares. type Follower struct { env Env kick chan struct{} // looks asks a look at what runs (RunsChanged): written only when the file it names moved. looks chan struct{} mu sync.Mutex srcs map[string]source // by module, of what runs now // cur is the source last taken, and written whether its write went through. cur source written bool state Written mod string said map[string]bool // grants is the grants part's key as last heard (nil when none). grants *delivery.GrantsUserList grantsRev uint64 // unwatched is why the grants part's key is not watched now; empty while it is, or where it need not be. unwatched string } // New is a follower for a node. func New(env Env) *Follower { if env.Now == nil { env.Now = time.Now } if env.Say == nil { env.Say = func(string) {} } if env.Backoff == nil { env.Backoff = func(int) time.Duration { return time.Minute } } f := &Follower{env: env, kick: make(chan struct{}, 1), looks: make(chan struct{}, 1), srcs: map[string]source{}, said: map[string]bool{}} if env.Grants != nil && env.Kept != nil { if raw, rev := env.Kept(); raw != nil { if g, err := delivery.DecodeGrantsUserList(raw); err == nil { f.grants, f.grantsRev = &g, rev } } } return f } // Again asks the follower to look at what runs and the file again: when what runs changed, after an apply of the bus // module, and when a person asks. It never blocks. func (f *Follower) Again() { select { case f.kick <- struct{}{}: default: } } // RunsChanged asks the follower to look at what this node runs again: the list is written again only when the file the // bus module declares moved. It never blocks. func (f *Follower) RunsChanged() { select { case f.looks <- struct{}{}: default: } } // State is the module whose list the follower writes ("" when nothing this node runs carries one) and what it last did. func (f *Follower) State() (string, Written) { f.mu.Lock() defer f.mu.Unlock() return f.mod, f.state } // Run follows the grants part's list until ctx ends. func (f *Follower) Run(ctx context.Context) error { f.RunsChanged() // the first look at what runs, at once // The grants part's key, on the node whose compositions carry the list: the bus node alone is granted it (mesh-sdk // buscompose.GrantsUserListFollows). Until its watch caught up nothing is written, so a new follower (one per link) // never writes a list older than the key; a watch refused or ended is tried again on the backoff, and meanwhile the // key last heard (kept on the machine) holds. var grants <-chan agent.Update var retry <-chan time.Time opened, grantsCaught, tries := false, false, 0 failed := func(why string) { grants, tries = nil, tries+1 retry = time.After(f.env.Backoff(tries)) f.mu.Lock() f.unwatched = why f.mu.Unlock() f.sayOnce("the grants part's user-list key is not watched, tried again: " + why) } open := func() { if grants != nil || retry != nil || f.env.Grants == nil || !f.busNode() { return } opened = true ch, err := f.env.Grants.Watch(ctx, delivery.GrantsUserListKey) if err != nil { failed(err.Error()) return } grants = ch } for { again := false select { case <-ctx.Done(): return nil case <-f.kick: again = true f.look() case <-f.looks: f.look() case <-retry: retry = nil case u, ok := <-grants: if !ok { failed("its watch ended") break } if u.Caught { grantsCaught, tries = true, 0 f.mu.Lock() f.unwatched = "" f.mu.Unlock() break } f.takeGrants(u) } open() if opened && !grantsCaught && grants != nil { continue // the switch is not heard yet: nothing is written before it is } f.mu.Lock() if f.unwatched != f.state.Unwatched { again = true // the have says whether the key is watched } f.mu.Unlock() f.settle(ctx, again) } } // busNode says something this node runs carries the bus's user list, or did. func (f *Follower) busNode() bool { f.mu.Lock() defer f.mu.Unlock() return f.cur.module != "" || len(f.srcs) > 0 } // look reads what this node runs now for the bus's user list: each module whose composition carries it. func (f *Follower) look() { var running []delivery.Composed if f.env.Running != nil { running = f.env.Running() } srcs := map[string]source{} for _, c := range running { if c.Node != "" && c.Node != f.env.Node { continue } raw, _, _, ok := userList(c.Resources) if !ok { continue } module := c.Module if module == "" { module = agent.NodeModule } srcs[module] = source{module: module, resource: raw} } f.mu.Lock() f.srcs = srcs f.mu.Unlock() } // takeGrants keeps the grants part's key as heard. A key deleted, or a value it cannot read, is said and changes nothing: // the key as last heard stands and the list loaded stays (novox/hq ADR 0571 clause 8, ADR 0547 clause 11; issue 1126). func (f *Follower) takeGrants(u agent.Update) { f.mu.Lock() defer f.mu.Unlock() if u.Deleted { f.sayOnceLocked("the grants part's user-list key is deleted, so the list loaded is kept") return } g, err := delivery.DecodeGrantsUserList(u.Value) if err != nil { f.sayOnceLocked("the grants part's user-list key cannot be read, so the list loaded is kept: " + err.Error()) return } f.grantsRev = u.Revision f.grants = &g f.keep(u.Value, u.Revision) } // keep keeps the key last heard on the machine (Env.Keep), so a follower that cannot watch it follows what it said. func (f *Follower) keep(value []byte, rev uint64) { if f.env.Keep != nil { f.env.Keep(value, rev) } } func (f *Follower) unwatchedNow() string { f.mu.Lock() defer f.mu.Unlock() return f.unwatched } func (f *Follower) key() string { if f.env.Key == nil { return "" } return f.env.Key() } // settle writes the grants part's list: when its key moved, or when asked. func (f *Follower) settle(ctx context.Context, again bool) { f.settleGrants(ctx, again) } // reportable says a have's user list says something new: a look again that found the file as it was says nothing. func reportable(prev, w Written) bool { return prev.Source != w.Source || prev.Revision != w.Revision || prev.Digest != w.Digest || prev.Error != w.Error || prev.Follows != w.Follows || prev.Key != w.Key || prev.Unwatched != w.Unwatched } // settleGrants writes the grants part's list, the object its key names, into the file the bus module's composition // declares, made 0600 (ADR 0547 clause 7): when the key moved, or when asked. With no key heard it writes nothing and // says why in the have. func (f *Follower) settleGrants(ctx context.Context, again bool) { f.mu.Lock() var g delivery.GrantsUserList heard := f.grants != nil if heard { g = *f.grants } rev := f.grantsRev // What runs now: the module written before while it still carries the list, else the first by name; what was // written before when nothing runs that carries it now (a look between two applies). file := f.cur if s, ok := f.srcs[f.cur.module]; ok { file = s } else if len(f.srcs) > 0 { names := make([]string, 0, len(f.srcs)) for m := range f.srcs { names = append(names, m) } sort.Strings(names) file = f.srcs[names[0]] } if file.module == "" { f.mu.Unlock() return // nothing this node runs declares the file: not the bus node } moved := file.module != f.cur.module || pathOf(file.resource) != pathOf(f.cur.resource) f.cur = file if !again && !moved && f.state.Source != "" && f.state.Revision == rev && f.written { f.mu.Unlock() return } f.mu.Unlock() w := Written{Source: delivery.GrantsUserListSource, Revision: rev, Wanted: g.Digest, At: f.env.Now().UTC(), Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()} var err error var changed bool resource, rerr := mode0600(file.resource) switch { case rerr != nil: err = fmt.Errorf("the bus's user-list file this node's composition declares cannot be read: %w", rerr) case !heard: err = fmt.Errorf("the grants part's user-list key is not heard yet; the list on the machine is kept") case g.Digest == "": err = fmt.Errorf("the grants part's key names no list yet; the list on the machine is kept") case f.env.Fetch == nil || f.env.Open == nil: err = fmt.Errorf("this engine cannot read or open the grants part's list") case g.SealedTo != "" && w.Key != "" && g.SealedTo != delivery.SealingKeyID(w.Key): err = fmt.Errorf("the grants part's list is sealed to another key than this node's (%s); the list on the machine is kept until it seals to this one", g.SealedTo) } var body []byte if err == nil { var object []byte if object, err = f.env.Fetch(ctx, g.Object); err == nil { body, err = delivery.OpenUserList(object, g.Digest, f.env.Open) } if err != nil { err = fmt.Errorf("the grants part's list %s could not be had: %w", short(g.Digest), err) } } if err == nil { w.Users = Users(body) if err = f.guard(resource, body); err != nil { if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil { w.Digest = Digest(disk) } } } if err == nil { w.Digest, changed, err = f.env.Write(ctx, file.module, resource, body) } else if w.Digest == "" && f.env.OnDisk != nil { if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil { w.Digest = Digest(disk) } } if err != nil { w.Error = err.Error() } f.mu.Lock() prev := f.state // The grants part's list is not handed to the vault: a handing written before stands (clause 9). w.Handed = prev.Handed f.mod, f.state = file.module, w f.written = err == nil f.mu.Unlock() at := fmt.Sprintf("%s@%d", delivery.GrantsUserListSource, rev) if err != nil { f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err)) } else if changed { f.env.Say(fmt.Sprintf("the bus's user list from the grants part (%s, revision %d) is written alone (%d users, %s); the bus reloads it itself", at, g.Revision, w.Users, short(w.Digest))) } if f.env.Report != nil && (changed || reportable(prev, w)) { f.env.Report(ctx, file.module, w) } } // Hold is c as the apply may write it (C3 of the switch's review): the list a composition carries is never written, so // its user-list resource holds the list on the machine instead (made 0600), or is left out when the machine holds none. // A list on the machine that cannot be read is an error: left out, the apply would remove the file as an orphan and the // bus would refuse every connection at its next reload, so the bus module's apply waits instead. A composition without // a user list is c unchanged. func (f *Follower) Hold(c delivery.Composed) (delivery.Composed, error) { out := c out.Resources = nil for _, raw := range c.Resources { if r, _, _, ok := userList([]json.RawMessage{raw}); ok { var held json.RawMessage body, err := f.env.OnDisk(r) if err != nil { return c, fmt.Errorf("the user list on the machine could not be read, so the apply waits and the file stays: %w", err) } if body != nil { var m map[string]any if json.Unmarshal(r, &m) == nil { delete(m, "digest") delete(m, "size") m["content"], m["mode"] = string(body), "0600" held, _ = json.Marshal(m) } } if held != nil { out.Resources = append(out.Resources, held) } continue } out.Resources = append(out.Resources, raw) } return out, nil } // mode0600 is a user-list resource made mode 0600, whatever its composition said (ADR 0547 clause 7). func mode0600(raw json.RawMessage) (json.RawMessage, error) { if len(raw) == 0 { return nil, fmt.Errorf("no resource") } var r map[string]any if err := json.Unmarshal(raw, &r); err != nil { return nil, err } r["mode"] = "0600" return json.Marshal(r) } // guard refuses a list that would lock the mesh out of its own bus: one that drops more than half the users the file on // the machine holds. A list that shrinks that far is a composer that read half the mesh, not half the mesh gone; it // waits for a person, and the bus keeps the list it has. (A list without the controller's user is no longer refused: // the controller ended, issue 1126.) func (f *Follower) guard(resource json.RawMessage, body []byte) error { names := Names(body) if f.env.OnDisk == nil { return nil } disk, err := f.env.OnDisk(resource) if err != nil || disk == nil { return nil } if had := Users(disk); had > 0 && len(names)*2 < had { return fmt.Errorf("refused: the list holds %d users and the one on the machine %d; a list that drops more "+ "than half is not written, and the list on the machine is kept", len(names), had) } return nil } func (f *Follower) sayOnceLocked(line string) { if !f.said[line] { f.said[line] = true f.env.Say(line) } } func (f *Follower) sayOnce(line string) { f.mu.Lock() said := f.said[line] f.said[line] = true f.mu.Unlock() if !said { f.env.Say(line) } } // userList is the bus's user list among a composition's resources: the resource, its content, and its digest when it // is given by digest rather than content. func userList(resources []json.RawMessage) (json.RawMessage, string, string, bool) { for _, raw := range resources { var head struct { ID string `json:"id"` Type string `json:"type"` Content string `json:"content"` Digest string `json:"digest"` } if json.Unmarshal(raw, &head) != nil || head.Type != "file" { continue } if head.ID == ResourceID || strings.HasSuffix(head.ID, "."+ResourceID) { return raw, head.Content, head.Digest, true } } return nil, "", "", false } // pathOf is where a file resource says its file is; "" when it says none. func pathOf(raw json.RawMessage) string { var head struct { Path string `json:"path"` } _ = json.Unmarshal(raw, &head) return head.Path } // Digest is content's digest as the have says it, `sha256:`. func Digest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) } // userEntry is one user of the list as the composer writes it (and the bus module's own tools read it). var userEntry = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`) // Users is how many users a list holds. func Users(content []byte) int { return len(userEntry.FindAllIndex(content, -1)) } // Names is the users a list holds, as it names them. func Names(content []byte) []string { var out []string for _, m := range userEntry.FindAllSubmatch(content, -1) { out = append(out, string(m[1])) } return out } func short(d string) string { d = strings.TrimPrefix(d, "sha256:") if len(d) > 12 { d = d[:12] } return d }