From 4edd0e526cdaa013c3211cad91722e131a1d3de2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:19:15 +0100 Subject: [PATCH] mesh-enrolment: issue, the operator's ask and the warrant, and again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue refuses at once naming every finding (a bad name, a name a node record, a placement key or an open enrolment holds, an expiry past a day, any call before the lease write), reserves the lowest free address by creating its key, writes the enrolment at asked and asks the operator (ADR 0587 §4). On the yes the warrant is recorded, the vault makes the token and both keys go live with the bus's expiry; a no or an expired ask takes both keys and says enrolment-refused. Tests added: three table-style tests; none deleted here (paid with 774/S59's merges). (issue 774, 774/S55) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../cmd/mesh-enrolment/issue.go | 306 ++++++++++++++++++ .../cmd/mesh-enrolment/issue_test.go | 133 ++++++++ .../cmd/mesh-enrolment/kit_test.go | 13 +- .../cmd/mesh-enrolment/tools.go | 37 ++- 4 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/issue.go create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go new file mode 100644 index 0000000..e6f6be2 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go @@ -0,0 +1,306 @@ +package main + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Event is what each of the seat's events carries (ADR 0587 §2): the key's whole content, which holds no secret and nothing +// derived from the token, and for an end the reason. Node is repeated at the top so a consumer reads it without the rest. +type Event struct { + Node string `json:"node"` + Enrolment Enrolment `json:"enrolment"` + Reason string `json:"reason,omitempty"` + // Issued names the enrolment-issued event that carried the content (for enrolment-expired, whose key is gone). + Issued string `json:"issued-event,omitempty"` +} + +// eventID is the message id an event of e is said under: the bus keeps one message of it, so an event said again after a +// crash is not said twice. +func eventID(event string, e Enrolment) string { + return event + "." + e.Node + "." + strconv.FormatInt(e.Issued.UnixNano(), 10) +} + +func (h *Holder) sayEvent(event string, e Enrolment, reason string) error { + return h.say(event, Event{Node: e.Node, Enrolment: e, Reason: reason}, eventID(event, e)) +} + +// IssueAnswer is what `issue` answers: the ask is the operator's to answer, and nothing else happens until they do. +type IssueAnswer struct { + Node string `json:"node"` + Address string `json:"address"` + Ask string `json:"ask"` + Expires time.Time `json:"expires"` + Says string `json:"says"` +} + +// Issue opens an enrolment (ADR 0587 §4): it refuses at once, naming every finding, a name that is no node name or that a +// node record, a placement key or an open enrolment holds, and every call before the lease write; chooses the lowest free +// address and reserves it by creating its key (the bus refuses a key that exists, so two issues never get one address); +// writes the key at stage asked; and asks the operator, on a channel that proves who answered. The token is made only on +// the operator's yes (Warranted). +func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnswer, error) { + var findings []string + if caller == "" || caller == stdio.Unproven { + findings = append(findings, "the caller is not proven by the runtime, and an enrolment records who asked") + } + if err := CheckNode(node); err != nil { + findings = append(findings, err.Error()) + } + if strings.TrimSpace(why) == "" { + findings = append(findings, "no why is given: the operator is asked why this machine joins") + } + if lasts == 0 { + lasts = DefaultExpiry + } + if lasts < 0 || lasts > MaxExpiry { + findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, MaxExpiry)) + } + if on, reason := h.issuing(); !on { + findings = append(findings, "issuing is off until the controller's lease is written to a holder that is no controller: "+reason+ + "; until then the controller's `token issue` is the only issuer (ADR 0587 §4)") + } + placements, perr := h.placements() + if perr != nil { + findings = append(findings, "placement cannot be read: "+perr.Error()) + } + if CheckNode(node) == nil { + if _, ok := placements[node]; ok { + findings = append(findings, node+" has a placement key already") + } + if h.hasRecord(node) { + findings = append(findings, node+" has a node record already") + } + if _, found, err := h.Store.Get(NodeKey(node)); err != nil { + findings = append(findings, "the enrolments cannot be read: "+err.Error()) + } else if found { + findings = append(findings, "an enrolment of "+node+" is open already: revoke it first") + } + } + if len(findings) > 0 { + return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %s", node, strings.Join(findings, "; ")) + } + now := h.now() + e := Enrolment{V: 1, Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked, Issued: now, Expires: now.Add(lasts)} + e.Ask = "enrol-" + node + "-" + now.UTC().Format("20060102t150405") + skip := map[string]bool{} + for { + reserved, err := h.reservedAddresses() + if err != nil { + return IssueAnswer{}, err + } + addr, err := nextAddress(placements, reserved, skip) + if err != nil { + return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %w", node, err) + } + switch err := h.Store.Create(AddressKey(addr), Reservation{V: 1, Node: node}, 0); { + case err == nil: + e.Address = addr + case errors.Is(err, ErrExists): + skip[addr] = true // another issue reserved it first: the next + continue + default: + return IssueAnswer{}, fmt.Errorf("the address %s cannot be reserved: %w", addr, err) + } + break + } + if err := h.Store.Create(NodeKey(node), e, 0); err != nil { + _ = h.Store.Delete(AddressKey(e.Address)) + if errors.Is(err, ErrExists) { + return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: an enrolment of it was opened meanwhile", node) + } + return IssueAnswer{}, fmt.Errorf("the enrolment of %s cannot be written: %w", node, err) + } + if err := h.raise(e); err != nil { + h.release(e) + return IssueAnswer{}, err + } + return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires, + Says: "the operator is asked on their channel; only their yes makes the token. mesh-enrolment.tokens shows where it stands"}, nil +} + +// release takes an enrolment's two keys away, the address key last (the node's key is what names the reservation's owner). +func (h *Holder) release(e Enrolment) { + if err := h.Store.Delete(NodeKey(e.Node)); err != nil { + h.logf("the key of %s could not be deleted and stays: %v", e.Node, err) + } + if err := h.Store.Delete(AddressKey(e.Address)); err != nil { + h.logf("the address key %s could not be deleted and stays: %v", e.Address, err) + } +} + +// AskOf is the ask of an enrolment, in the words the operator reads: that a machine of this name may join until the +// expiry at this address. Its yes binds the act (name, address, expiry). +func (h *Holder) AskOf(e Enrolment) (asks.Ask, error) { + act := asks.Act{"verb": Seat + ".issue", "module": Module, "node": e.Node, "address": e.Address, + "expires": e.Expires.UTC().Format(time.RFC3339)} + bind, err := asks.ActDigest(act) + if err != nil { + return asks.Ask{}, err + } + until := e.Expires.UTC().Format("2006-01-02 15:04 MST") + closes := h.now().Add(h.AskLasts) + if closes.After(e.Expires) { + closes = e.Expires + } + return asks.Ask{ID: e.Ask, Headline: "Let the machine " + e.Node + " join the mesh?", + Explanation: "A machine named " + e.Node + " may join the mesh until " + until + ", at the private address " + e.Address + ".\n" + + "Your yes lets the vault make a one-time token for it; you open the token at your own desk and type it on the new machine.\n" + + "What the machine runs is placed later, as for any machine.", + Who: asks.Operator, + Options: []asks.Option{{ID: "yes", Label: "Yes, let it join", Does: "The vault makes a one-time token for " + e.Node + ", valid until " + until + ".", Level: asks.Approve, Binds: bind}, + {ID: "no", Label: "No", Does: "Nothing is made; the address is free again.", Level: asks.Acknowledge}}, + Expires: closes, OnExpiry: "No token is made and the address is free again.", About: "mesh-enrolment-" + e.Node, + Details: "Asked by " + e.Caller + ": " + e.Why}, nil +} + +// raise asks the operator; a question that cannot be asked is taken back at the router, since a publish that timed out may +// have reached it, so a yes cannot land on an enrolment the holder has forgotten. +func (h *Holder) raise(e Enrolment) error { + ask, err := h.AskOf(e) + if err != nil { + return err + } + if err := h.Asker.Ask(ask); err != nil { + if cerr := h.Asker.Cancel(ask.ID); cerr != nil { + h.logf("the ask %s that could not be published was not taken back at the router: %v", ask.ID, cerr) + } + return fmt.Errorf("the operator could not be asked about %s: %w", e.Node, err) + } + return nil +} + +// byAsk finds the open enrolment an ask belongs to. +func (h *Holder) byAsk(ask string) (Enrolment, Row, bool, error) { + rows, open, err := h.openEnrolments() + if err != nil { + return Enrolment{}, Row{}, false, err + } + for n, e := range open { + if e.Ask == ask { + return e, rows[n], true, nil + } + } + return Enrolment{}, Row{}, false, nil +} + +// Warranted is the operator's answer, handed once by the asker (asker.Client.Listen): a no ends the enrolment; a yes records +// the warrant, asks the vault for the token, moves the key to live with the bus's expiry at the token's, and says +// enrolment-issued (ADR 0587 §4, §5). +func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error { + e, _, found, err := h.byAsk(w.Ask) + if err != nil { + return err + } + if !found { + return fmt.Errorf("the warrant answers the ask %s, which no open enrolment holds: nothing is made", w.Ask) + } + if e.Stage != StageAsked { + return nil // answered already + } + if o.ID != "yes" { + return h.refuse(e, "the operator declined") + } + if !h.now().Before(e.Expires) { + return h.refuse(e, "the enrolment's expiry passed before the operator answered") + } + if h.Warrant != nil { + if err := h.Warrant(w.Ask); err != nil { + return fmt.Errorf("the warrant %s is not recorded in the hand-act log, so no token is made (mesh-enrolment.again asks again): %w", w.Ask, err) + } + } + if _, err := h.Vault.Token(e.Node, e.Expires); err != nil { + return fmt.Errorf("the vault did not make the token of %s (mesh-enrolment.again asks the operator again): %w", e.Node, err) + } + e.Stage = StageLive + ttl := h.lifetimeOf(e.Expires) + if _, err := h.Store.Put(NodeKey(e.Node), e, ttl); err != nil { + return fmt.Errorf("the enrolment of %s cannot be moved to live: %w", e.Node, err) + } + if _, err := h.Store.Put(AddressKey(e.Address), Reservation{V: 1, Node: e.Node}, ttl); err != nil { + return fmt.Errorf("the address key %s cannot be given its expiry: %w", e.Address, err) + } + return h.sayEvent(EventIssued, e, "") +} + +// refuse ends an enrolment at stage asked: both keys go and enrolment-refused carries the key's content (ADR 0587 §4). +func (h *Holder) refuse(e Enrolment, reason string) error { + if err := h.sayEvent(EventRefused, e, reason); err != nil { + return fmt.Errorf("enrolment-refused of %s cannot be said, so its keys stay: %w", e.Node, err) + } + h.release(e) + return nil +} + +// Said is the asker's note of an ask that ended with no yes or no (expired, or refused by the router): the enrolment ends. +func (h *Holder) Said(hd asker.Handled, w asks.Warrant, err error) { + if err != nil { + h.logf("the answer to %s (%s) ended in an error: %v", w.Ask, hd, err) + } + if hd != asker.Recorded { + return + } + e, _, found, ferr := h.byAsk(w.Ask) + if ferr != nil || !found || e.Stage != StageAsked { + return + } + if rerr := h.refuse(e, "the ask ended without an answer ("+string(w.Outcome)+")"); rerr != nil { + h.logf("%v", rerr) + } +} + +// Again asks the operator once more, when the router no longer holds the ask of an enrolment that is still at stage asked +// (ADR 0587 §8): a new ask under a new id, for the same node, address and expiry. +func (h *Holder) Again(node string) (IssueAnswer, error) { + row, found, err := h.Store.Get(NodeKey(node)) + if err != nil { + return IssueAnswer{}, err + } + if !found { + return IssueAnswer{}, fmt.Errorf("no enrolment of %s is open", node) + } + e, err := decodeEnrolment(row.Value) + if err != nil { + return IssueAnswer{}, err + } + if e.Stage != StageAsked { + return IssueAnswer{}, fmt.Errorf("the enrolment of %s is %s, not asked: there is nothing to ask again (a redeem stopped midway is finished by the machine calling redeem again)", node, e.Stage) + } + if !h.now().Before(e.Expires) { + return IssueAnswer{}, fmt.Errorf("the enrolment of %s has expired; revoke it and issue it again", node) + } + if h.Asker != nil { + if state, _, werr := h.whereIs(e.Ask); werr == nil && (state == "open" || state == "held") { + return IssueAnswer{}, fmt.Errorf("the ask %s is still %s at the router: the operator can answer it", e.Ask, state) + } + } + old := e.Ask + e.Ask = "enrol-" + node + "-" + h.now().UTC().Format("20060102t150405") + if e.Ask == old { + e.Ask += "b" + } + if _, err := h.Store.Update(NodeKey(node), e, row.Revision); err != nil { + return IssueAnswer{}, fmt.Errorf("the enrolment of %s moved meanwhile: %w", node, err) + } + if err := h.raise(e); err != nil { + return IssueAnswer{}, err + } + return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires, Says: "the operator is asked again"}, nil +} + +// whereIs is the router's state of an ask, when the asker can read it. +func (h *Holder) whereIs(id string) (string, *asks.Warrant, error) { + if w, ok := h.Asker.(interface { + Where(string) (string, *asks.Warrant, error) + }); ok { + return w.Where(id) + } + return "", nil, errors.New("the asker cannot read the router's record") +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go new file mode 100644 index 0000000..f94331a --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +var yes = asks.Option{ID: "yes"} + +// 774/S55/AC1: issue refuses, naming every finding, what it must: the lease with the controller, a bad name, a name a node +// record, a placement key or an open enrolment holds, and an expiry past a day. +func TestIssueRefusesNamingEveryFinding(t *testing.T) { + cases := []struct { + name string + setup func(r *rig) + node string + lasts time.Duration + want []string + }{ + {"the lease names the controller", func(r *rig) { r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` }, "ace", 0, + []string{"issuing is off until the controller's lease is written", "token issue"}}, + {"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}}, + {"a placement key and a node record", func(r *rig) { + r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}` + r.rd["node-engine.have"]["ace"] = `{}` + }, "ace", 0, []string{"ace has a placement key already"}}, + {"an open enrolment", func(r *rig) { r.seed("ace", "10.9.0.2", StageLive) }, "ace", 0, []string{"an enrolment of ace is open already"}}, + } + for _, c := range cases { + r := newRig(t) + if c.setup != nil { + c.setup(r) + } + _, err := r.h.Issue("person.operator", c.node, "a new machine", c.lasts) + if err == nil { + t.Fatalf("%s: issued", c.name) + } + for _, w := range c.want { + mustContain(t, err.Error(), w) + } + if len(r.asker.asked) != 0 || strings.Contains(strings.Join(r.keys(), " "), "address.10.9.0.3") { + t.Errorf("%s: asked %v, keys %v", c.name, r.asker.asked, r.keys()) + } + } +} + +// 774/S55/AC2: two issues at once for two nodes hold two addresses; a yes makes the token, moves both keys to live with the +// token's expiry, records the warrant, says enrolment-issued, and a second answer changes nothing. +func TestTwoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) { + r := newRig(t) + var wg sync.WaitGroup + addrs := make([]string, 2) + for i, n := range []string{"ace", "g14"} { + wg.Add(1) + go func() { + defer wg.Done() + a, err := r.h.Issue("person.operator", n, "a new machine", 0) + if err != nil { + t.Error(err) + } + addrs[i] = a.Address + }() + } + wg.Wait() + if addrs[0] == "" || addrs[0] == addrs[1] || addrs[0] == "10.9.0.1" || addrs[1] == "10.9.0.1" { + t.Fatalf("addresses %v", addrs) + } + e, _ := r.enrolment("ace") + if e.Stage != StageAsked || e.Caller != "person.operator" || e.Ask == "" || len(r.asker.asked) != 2 { + t.Fatalf("%+v, asked %v", e, r.asker.asked) + } + ask, _ := r.h.AskOf(e) + if ask.Options[0].Level != asks.Approve || ask.Options[0].Binds == "" || ask.Check(r.now) != nil { + t.Errorf("the ask does not authorise the act: %+v, %v", ask.Options[0], ask.Check(r.now)) + } + if err := r.h.Warranted(yes, asks.Warrant{Ask: e.Ask}); err != nil { + t.Fatal(err) + } + live, _ := r.enrolment("ace") + if live.Stage != StageLive || r.st.ttl[NodeKey("ace")] != time.Hour || r.st.ttl[AddressKey(live.Address)] != time.Hour || + len(r.warr) != 1 || r.warr[0] != e.Ask || r.events()[0] != EventIssued { + t.Errorf("live %+v, ttl %v/%v, warrants %v, events %v", live, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey(live.Address)], r.warr, r.events()) + } + if strings.Contains(string(r.said[0].Body), "secret") || r.said[0].ID != eventID(EventIssued, live) { + t.Errorf("the event %s carries a secret-like word or no stable id", r.said[0].Body) + } + if err := r.h.Warranted(yes, asks.Warrant{Ask: e.Ask}); err != nil || len(r.said) != 1 { + t.Errorf("a second answer said %v: %v", r.events(), err) + } +} + +// 774/S55/AC3: a declined or expired ask, heard, takes both keys away, and enrolment-refused carries the key's content; a +// vault that fails leaves the enrolment at asked, for `again` to ask once more. +func TestADeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain(t *testing.T) { + r := newRig(t) + a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0) + if err := r.h.Warranted(asks.Option{ID: "no"}, asks.Warrant{Ask: a.Ask}); err != nil { + t.Fatal(err) + } + if len(r.keys()) != 0 || r.events()[0] != EventRefused { + t.Fatalf("keys %v events %v", r.keys(), r.events()) + } + mustContain(t, string(r.said[0].Body), `"address":"`+a.Address+`"`) + b, _ := r.h.Issue("person.operator", "g14", "a new machine", 0) + r.h.Said(asker.Recorded, asks.Warrant{Ask: b.Ask, Outcome: asks.OutcomeExpired}, nil) + if len(r.keys()) != 0 || r.events()[1] != EventRefused { + t.Fatalf("keys %v events %v", r.keys(), r.events()) + } + c, _ := r.h.Issue("person.operator", "novox", "a new machine", 0) + r.vault.fail["token novox"] = true + if err := r.h.Warranted(yes, asks.Warrant{Ask: c.Ask}); err == nil { + t.Fatal("a vault that failed was taken for a token") + } + if e, _ := r.enrolment("novox"); e.Stage != StageAsked { + t.Fatalf("stage %s", e.Stage) + } + r.now = r.now.Add(time.Minute) + again, err := r.h.Again("novox") + if err != nil || again.Ask == c.Ask || len(r.asker.asked) != 4 { + t.Fatalf("again %+v, %v, asked %v", again, err, r.asker.asked) + } + if err := r.h.Warranted(yes, asks.Warrant{Ask: again.Ask}); err != nil { + t.Fatal(err) + } + if e, _ := r.enrolment("novox"); e.Stage != StageLive { + t.Errorf("stage %s", e.Stage) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index e907b6b..9107ca7 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -142,18 +142,26 @@ func (v *fakeVault) SignReply(node string, reply []byte) (json.RawMessage, error } type fakeAsker struct { + mu sync.Mutex asked, cancelled []string err error } func (a *fakeAsker) Ask(k asks.Ask) error { + a.mu.Lock() + defer a.mu.Unlock() if a.err != nil { return a.err } a.asked = append(a.asked, k.ID) return nil } -func (a *fakeAsker) Cancel(id string) error { a.cancelled = append(a.cancelled, id); return nil } +func (a *fakeAsker) Cancel(id string) error { + a.mu.Lock() + defer a.mu.Unlock() + a.cancelled = append(a.cancelled, id) + return nil +} type said struct { Event, ID string @@ -162,6 +170,7 @@ type said struct { // rig is a holder over fakes, with the lease handed to mesh-grants, one hub in placement and a clock the test moves. type rig struct { + mu sync.Mutex t *testing.T h *Holder st *memStore @@ -189,6 +198,8 @@ func newRig(t *testing.T) *rig { r.h = &Holder{Store: r.st, Read: r.rd, Vault: r.vault, Asker: r.asker, AskLasts: time.Hour, Say: func(event string, body any, id string) error { raw, _ := json.Marshal(body) + r.mu.Lock() + defer r.mu.Unlock() r.said = append(r.said, said{event, id, raw}) return nil }, diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index 652bbc4..787a9d2 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "time" @@ -9,6 +10,8 @@ import ( // Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8). const ( + IssueTool = Seat + ".issue" + AgainTool = Seat + ".again" TokensTool = Seat + ".tokens" ) @@ -31,11 +34,43 @@ func flag(a map[string]any, k string) bool { return a[k] == true || strings.EqualFold(arg(a, k), "true") } -var _ = time.Second +// lastsOf reads an expiry as a duration; none is the default. +func lastsOf(s string) (time.Duration, error) { + if s == "" { + return 0, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("the expiry %q is no duration (for example 1h): %w", s, err) + } + return d, nil +} // Tools are what the runtime serves. func Tools(h *Holder) []stdio.Tool { return []stdio.Tool{ + {Name: IssueTool, + Description: "Open an enrolment: let a new machine join the mesh (novox/hq ADR 0587 §4). Takes the node's name, a why and an " + + "expiry (one hour unless given, at most 24 hours). Refused at once, naming every finding, for a name that is no node name " + + "or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is " + + "written to a holder that is no controller. Otherwise it chooses the lowest free private-network address, reserves it, " + + "writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes makes " + + "the token (the vault makes it, this seat never holds it). The caller is the one the runtime proves.", + Input: obj(map[string]any{"node": str("the new machine's name"), "why": str("why this machine joins"), + "expires": str("how long the token lasts, e.g. 1h (default) up to 24h")}, "node", "why"), + Run: func(a map[string]any) (any, error) { + lasts, err := lastsOf(arg(a, "expires")) + if err != nil { + return nil, err + } + return h.Issue(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"), lasts) + }}, + {Name: AgainTool, + Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " + + "(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " + + "by the machine calling redeem again with the same keys.", + Input: obj(map[string]any{"node": str("the machine")}, "node"), + Run: func(a map[string]any) (any, error) { return h.Again(arg(a, "node")) }}, {Name: TokensTool, Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " + "redeeming), address, expiry, ask, revision and age; whether issuing is on and why (it is on only from the controller's " +