From 447bcf3a6b885b01f58a29751418f354e745e1bf Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:27:54 +0100 Subject: [PATCH] The assigner takes an add-node from mesh-enrolment and writes the joining node with no ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit propose takes the enrolment's warrant {node, address, ask, warrant} and refuses an add-node from any caller but mesh-enrolment, with no warrant or with a warrant naming another node or address (delivery.AddNodeRefusal); the joining node counts as having a record for this one change, as ADR 0587 §7 changes ADR 0567 §2. The assigner's process then writes placement. at once, with no node to answer and no ask raised, and says placement-changed carrying the enrolment's warrant; the hand-act record of that warrant is the enrolment's, made when it was given. The holder of mesh-enrolment calls propose with the change, the keys and the warrant read from the router's record of the ask. Tests: rows appended to the seat's verbs test and a case to the whole-seat integration test; none added, so none to pay for (the budget is paid in 774/S60). (issue 774, 774/S59) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../integration_test.go | 26 +++++++++++ .../cmd/mesh-delivery-assigner/seat.go | 38 ++++++++++++++++ .../cmd/mesh-delivery-assigner/seat_tools.go | 43 ++++++++++++++++++- .../mesh-delivery-assigner/seat_tools_test.go | 21 +++++++++ .../internal/assign/seat.go | 16 ++++++- .../cmd/mesh-enrolment/holder.go | 35 ++++----------- .../cmd/mesh-enrolment/kit_test.go | 14 +++--- .../mesh-enrolment/cmd/mesh-enrolment/main.go | 22 ++++++++-- .../cmd/mesh-enrolment/redeem.go | 21 ++++----- .../cmd/mesh-enrolment/redeem_test.go | 3 +- .../cmd/mesh-enrolment/vault.go | 7 +-- 11 files changed, 193 insertions(+), 53 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 9e48b6a..ec6fb24 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -1133,4 +1133,30 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { if failed.Verdict == nil || !strings.Contains(strings.Join(failed.Verdict.Fails, "; "), "laptop (offline) did not answer") { t.Errorf("the silent node's verdict = %+v", failed.Verdict) } + + // 774/S59 (ADR 0587 §7): once the lease is handed away, a joining node is written on the enrolment's warrant with no node to + // answer and no ask raised, and placement-changed carries that warrant. + b.putJSON(t, buscompose.LeaseBucket, buscompose.ControllerLeaseKey, []byte(`{"handed-to":"mesh-grants","handed-by":"operator"}`)) + for len(heard) > 0 { + <-heard + } + joined, err := verbs.doPropose(map[string]any{"why": "enrolment of ace", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`, + "changes": `[{"op":"add-node","node":"ace","decided":{"address":"10.9.0.2"},"keys":{"identity":"aWQ=","sealing":"c2Vh"}}]`}, "novox.mesh-enrolment") + if err != nil { + t.Fatal(err) + } + select { + case e := <-heard: + if e.Proposal.ID != joined.(proposed).Proposal.ID || e.Warrant == nil || e.Warrant.Warrant != "enrol-ace-1@2026-10-28T20:00:00Z" || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" { + t.Errorf("placement-changed for the joining node = %+v", e) + } + case <-time.After(15 * time.Second): + t.Fatal("no placement-changed heard for the joining node") + } + if ace, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), "10.9.0.2") { + t.Errorf("the joining node's key = %v, %v", ace, err) + } + if akey, _ := delivery.PlacementAskKey(joined.(proposed).Proposal.ID); b.hasKey(delivery.AssignerAsksBucket, akey) || b.hasKey(delivery.PlacementProposalsBucket, joined.(proposed).Proposal.ID) { + t.Error("an ask was raised for the joining node, or its proposal stays open") + } } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index c8ef722..3dc3358 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -268,6 +268,10 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos p.endProposal(ctx, prop, "adopt and hand-back are gone since the lease write: placement is the mesh's alone") return } + if prop.Warrant != nil { + p.addNode(ctx, prop, revs) + return + } akey, _ := delivery.PlacementAskKey(prop.ID) again := !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At)) if prop.State == delivery.ProposalAsked { @@ -544,6 +548,40 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, p.followWrite(prop, answer, changes) } +// addNode writes a joining node on the warrant of the enrolment that issued its token (ADR 0587 §7): the operator's Approve +// was the issue's, so no node is waited on and no ask is raised. The key is created at the revision it was laid over (none: a +// node with a key is no joining node); the enrolment recorded the warrant in the hand-act log when it was given, so this does +// not record it again; placement-changed carries the warrant. A key written meanwhile ends the proposal, saying so. +func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, revs map[string]uint64) { + current, _ := p.seatPlacements() + if moved(prop, revs) { + p.endProposal(ctx, prop, "a key it was laid over was written since: the enrolment proposes the node again") + return + } + laid, findings := delivery.LayChanges(current, prop.Changes) + node := prop.Changes[0].Node + value, ok := laid[node] + if len(findings) > 0 || !ok { + p.endProposal(ctx, prop, "the node cannot be added: "+strings.Join(findings, "; ")) + return + } + rev, err := p.writeNode(ctx, node, &value) + if err != nil { + p.endProposal(ctx, prop, "the node's key was not written: "+err.Error()) + return + } + c := delivery.ChangeOf(delivery.Placement{Node: node}, value) + c.Before, c.After = prop.Base[node], rev + p.deleteCandidates(ctx, prop.ID) + p.deletePulses(ctx, prop.ID) + _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) + p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + w := prop.Warrant + p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant, + Words: fmt.Sprintf("the operator approved the machine %s at %s through the enrolment's ask %s", w.Node, w.Address, w.Ask)}, + []delivery.PlacementChange{c}) +} + // followWrite follows the keys a warrant wrote: the warrant recorded and placement-changed said. No verb of the // controller is called: the placement is the one source of where modules run (ADR 0567 §8, issue 774). func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go index 15a565e..2ef4889 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go @@ -217,9 +217,16 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { if err != nil { return nil, err } + warrant, err := warrantOf(args["warrant"]) + if err != nil { + return nil, err + } now := s.now() p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, - Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded}) + Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant}) + if refusal := delivery.AddNodeRefusal(p, moduleOfCaller(by)); refusal != "" { + findings = append(findings, refusal) + } if len(findings) > 0 { return nil, fmt.Errorf("the proposal is refused: %s", strings.Join(findings, "; ")) } @@ -229,6 +236,40 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { return proposed{Proposal: p, Follow: followProposal(p.ID)}, nil } +// warrantOf reads propose's warrant, an enrolment's for an add-node: an object, given as one or as its text; none is none. +func warrantOf(v any) (*delivery.AddNodeWarrant, error) { + var raw []byte + switch w := v.(type) { + case nil: + return nil, nil + case string: + if strings.TrimSpace(w) == "" { + return nil, nil + } + raw = []byte(w) + default: + var err error + if raw, err = json.Marshal(w); err != nil { + return nil, err + } + } + var out delivery.AddNodeWarrant + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&out); err != nil { + return nil, fmt.Errorf("the warrant is no enrolment's warrant {node, address, ask, warrant}: %w", err) + } + return &out, nil +} + +// moduleOfCaller is the module a caller is, as the runtime proves it: a module's bus user is `.`. +func moduleOfCaller(by string) string { + if _, module, ok := strings.Cut(by, "."); ok { + return module + } + return by +} + // doWithdraw is the verb withdraw (§2): the proposal is marked, and the assigner deletes it and its candidates and says // placement-withdrawn carrying it whole. func (s *seatTools) doWithdraw(args map[string]any, by string) (any, error) { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index 6e1d736..82142d1 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -150,6 +150,27 @@ func TestTheSeatsVerbs(t *testing.T) { if _, err := s.doPropose(map[string]any{"why": "hold", "changes": `[{"op":"hold","node":"anchor","module":"web","claim":"node-shell","scope":"node"}]`}, "op"); err != nil { t.Errorf("a hold: %v", err) } + // 774/S59/AC1 (ADR 0587 §7): an add-node from mesh-enrolment whose warrant names this node and this address is proposed, with + // the node a joining one with no record; from another caller, naming another node or address, or with no warrant it is refused. + addNode := func(by, warrant string) error { + _, err := s.doPropose(map[string]any{"why": "enrolment of ace", "warrant": warrant, "changes": []any{map[string]any{"op": "add-node", "node": "ace", + "decided": map[string]any{"address": "10.9.0.2"}, "keys": map[string]any{"identity": "aWQ=", "sealing": "c2Vh"}}}}, by) + return err + } + good := `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}` + for _, c := range []struct{ by, warrant, want string }{ + {"novox.mesh-grants", good, "mesh-enrolment's alone"}, + {"novox.mesh-enrolment", `{"node":"g14","address":"10.9.0.2","ask":"a","warrant":"a@t"}`, "carries the warrant of g14"}, + {"novox.mesh-enrolment", `{"node":"ace","address":"10.9.0.9","ask":"a","warrant":"a@t"}`, "carries the warrant of ace at 10.9.0.9"}, + {"novox.mesh-enrolment", "", "carries no warrant"}, + } { + if err := addNode(c.by, c.warrant); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("an add-node by %s with warrant %q: %v, want a refusal saying %q", c.by, c.warrant, err, c.want) + } + } + if err := addNode("novox.mesh-enrolment", good); err != nil { + t.Errorf("an add-node from mesh-enrolment: %v", err) + } if _, err := s.doHandBack(map[string]any{"why": "back"}, "op"); err == nil || !strings.Contains(err.Error(), "since the lease write") { t.Errorf("a hand-back: %v", err) } diff --git a/modules/mesh-delivery-assigner/internal/assign/seat.go b/modules/mesh-delivery-assigner/internal/assign/seat.go index 435ac4e..3b2dc1e 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat.go @@ -24,6 +24,8 @@ type ProposeIn struct { Revisions map[string]uint64 Held func(module string) bool Recorded func(node string) bool + // Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7); only that change has one. + Warrant *delivery.AddNodeWarrant } // Propose lays a proposal over the current keys (§2): the proposal (state answering, each changed node's base revision), @@ -33,7 +35,7 @@ type ProposeIn struct { // record). A key whose value the proposal leaves as it is changes nothing and is not laid. func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Placement, []string) { p := delivery.PlacementProposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(), - Changes: in.Changes, Base: map[string]uint64{}, State: delivery.ProposalAnswering} + Changes: in.Changes, Warrant: in.Warrant, Base: map[string]uint64{}, State: delivery.ProposalAnswering} var findings []string if p.By == "" { findings = append(findings, "the runtime names no caller: a proposal is made by someone") @@ -75,6 +77,18 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla if recorded == nil { recorded = func(string) bool { return false } } + // A joining node writes its record only once it holds its account, which the add-node's write gives it: this one change by + // this one caller is the exception to a node with no record (ADR 0587 §7, changing ADR 0567 §2). + joining := map[string]bool{} + for _, c := range in.Changes { + if c.Op == delivery.OpAddNode { + joining[c.Node] = true + } + } + if len(joining) > 0 { + was := recorded + recorded = func(node string) bool { return joining[node] || was(node) } + } for _, r := range delivery.CheckPlacements(all, held, recorded) { findings = append(findings, r) } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go index 7da3442..f6dbdde 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go @@ -55,7 +55,9 @@ type Holder struct { // Warrant and Act record in the hand-act log, through the seat mesh-hand-acts: the operator's warrant for an ask, and an // act done by hand (a revoke). Warrant func(ask string) error - Act func(what, why, cause string) error + // WarrantOf is the warrant's id as the teller wrote it (the ask's id and when), read from the router's record of the ask. + WarrantOf func(ask string) (string, error) + Act func(what, why, cause string) error // Loaded says whether the bus node loaded the bus user (ADR 0587 §3: redeem answers once node. is in its list). Loaded func(user string) (bool, error) Now func() time.Time @@ -91,32 +93,13 @@ func (h *Holder) say(event string, body any, id string) error { return h.Say(event, body, id) } -// AddNode is what the holder asks mesh-placement to add (ADR 0587 §7): the node, its address, the public halves of its -// keys, and the issue's warrant, which names this node and this address. +// AddNode is what the holder asks mesh-placement to add (ADR 0587 §7): the proposal's one change, a joining node with the +// address the operator approved and the public halves of its keys, and the issue's warrant, which names this node and this +// address (delivery.AddNodeWarrant). type AddNode struct { - Node string `json:"node"` - Address string `json:"address"` - Why string `json:"why"` - Keys NodeKeys `json:"keys"` - Warrant Warrant `json:"warrant"` -} - -// NodeKeys are the public halves of a joining node's keys: identity, private network, sealing and serving, and the -// tunnel it found, as delivery.Redeem presents them. -type NodeKeys struct { - Public string `json:"public_key"` - Overlay string `json:"overlay_key,omitempty"` - Sealing string `json:"sealing_key,omitempty"` - Serving string `json:"serving_key,omitempty"` - Tunnel json.RawMessage `json:"tunnel,omitempty"` -} - -// Warrant is the issue's warrant as the proposal carries it: the ask the operator answered, the node and the address it -// named. -type Warrant struct { - Ask string `json:"ask"` - Node string `json:"node"` - Address string `json:"address"` + Why string + Change delivery.PlacementProposalChange + Warrant delivery.AddNodeWarrant } // issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587 diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index 4325087..3ace80b 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -211,15 +211,17 @@ func newRig(t *testing.T) *rig { AddNode: func(a AddNode) error { r.added = append(r.added, a) if r.placeErr == nil { // placement writes the node as the assigner does on the warrant - r.rd["mesh-delivery-assigner.placement"][a.Node+".node"] = `{"v":1,"node":"` + a.Node + `","decided":{"address":"` + a.Address + `"},"modules":[]}` + n := a.Change.Node + r.rd["mesh-delivery-assigner.placement"][n+".node"] = `{"v":1,"node":"` + n + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}` } return r.placeErr }, - Warrant: func(ask string) error { r.warr = append(r.warr, ask); return nil }, - Act: func(what, why, cause string) error { r.acts = append(r.acts, what); return nil }, - Loaded: func(string) (bool, error) { return r.loaded, nil }, - Now: func() time.Time { return r.now }, - Logf: func(f string, a ...any) { t.Logf(f, a...) }} + Warrant: func(ask string) error { r.warr = append(r.warr, ask); return nil }, + WarrantOf: func(ask string) (string, error) { return ask + "@2026-11-02T10:00:00Z", nil }, + Act: func(what, why, cause string) error { r.acts = append(r.acts, what); return nil }, + Loaded: func(string) (bool, error) { return r.loaded, nil }, + Now: func() time.Time { return r.now }, + Logf: func(f string, a ...any) { t.Logf(f, a...) }} return r } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go index 99cb6b0..a9ae270 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -92,13 +92,15 @@ func newHolder() *Holder { call := Call(stdio.Ask) rec := handacts.Recorder{Ask: stdio.Ask} rd := runtimeReader{} + client := asker.New("asked") return &Holder{ - Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: asker.New("asked"), + Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: client, Say: func(event string, body any, id string) error { return stdio.SeatPublish(SeatEventSubject(event), body, id) }, - AddNode: addNodeOver(call), - Warrant: func(ask string) error { _, err := rec.RecordWarrant(Module, ask); return err }, + AddNode: addNodeOver(call), + WarrantOf: warrantOf(client), + Warrant: func(ask string) error { _, err := rec.RecordWarrant(Module, ask); return err }, Act: func(what, why, cause string) error { _, err := rec.Record(handacts.Request{What: what, Why: why, Cause: cause}) return err @@ -110,6 +112,20 @@ func newHolder() *Holder { } } +// warrantOf is the warrant id the teller writes, `@`, read from the router's record of the ask. +func warrantOf(c *asker.Client) func(string) (string, error) { + return func(ask string) (string, error) { + state, w, err := c.Where(ask) + if err != nil { + return "", err + } + if w == nil { + return "", fmt.Errorf("the router records the ask %s as %q, with no warrant", ask, state) + } + return ask + "@" + w.At.UTC().Format(time.RFC3339Nano), nil + } +} + // loadedVia says a bus user is loaded when the grants part's last pass lists it among the composed users and the bus node's // loaded list holds it (its credentials row says loaded). func loadedVia(rd Reader) func(string) (bool, error) { diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go index e2a01a0..38f38af 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go @@ -10,11 +10,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" ) -// keysOf are the public halves a redeem presented. -func keysOf(a delivery.Redeem) NodeKeys { - return NodeKeys{Public: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey, Tunnel: a.Tunnel} -} - // Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be // enrol., for a key at stage live (or at redeeming with the same keys' digest), checks the proof against the identity key // presented, and then, each step read back and none repeated that is done: spends the token (the key to redeeming, by @@ -50,7 +45,7 @@ func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer if err != nil { return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err) } - if err := h.place(e, keysOf(a)); err != nil { + if err := h.place(e, a); err != nil { return delivery.RedeemAnswer{}, err } if h.Loaded != nil { @@ -122,7 +117,7 @@ func (h *Holder) spend(node, digest string) (Enrolment, error) { } // place writes the node into placement with the issue's warrant, unless placement holds it at the approved address already. -func (h *Holder) place(e Enrolment, keys NodeKeys) error { +func (h *Holder) place(e Enrolment, a delivery.Redeem) error { placements, err := h.placements() if err != nil { return fmt.Errorf("placement cannot be read (call redeem again with the same keys): %w", err) @@ -130,11 +125,17 @@ func (h *Holder) place(e Enrolment, keys NodeKeys) error { if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address { return nil } - if h.AddNode == nil { + if h.AddNode == nil || h.WarrantOf == nil { return errors.New("no route to mesh-placement") } - if err := h.AddNode(AddNode{Node: e.Node, Address: e.Address, Why: "enrolment of " + e.Node + ": " + e.Why, Keys: keys, - Warrant: Warrant{Ask: e.Ask, Node: e.Node, Address: e.Address}}); err != nil { + warrant, err := h.WarrantOf(e.Ask) + if err != nil { + return fmt.Errorf("the operator's warrant for %s cannot be read from the router (call redeem again with the same keys): %w", e.Ask, err) + } + change := delivery.PlacementProposalChange{Op: delivery.OpAddNode, Node: e.Node, Decided: &delivery.NodeDecided{Address: e.Address}, + Keys: &delivery.JoiningKeys{Identity: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey}} + if err := h.AddNode(AddNode{Why: "enrolment of " + e.Node + ": " + e.Why, Change: change, + Warrant: delivery.AddNodeWarrant{Node: e.Node, Address: e.Address, Ask: e.Ask, Warrant: warrant}}); err != nil { return fmt.Errorf("%w (call redeem again with the same keys)", err) } return nil diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go index 5d173d9..9dad26f 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go @@ -77,7 +77,8 @@ func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) { if e.Stage != StageRedeeming || e.KeysDigest != good.KeysDigest() || r.st.ttl[NodeKey("ace")] != 0 || r.st.ttl[AddressKey("10.9.0.2")] != 0 { t.Errorf("spent %+v, ttl %v/%v", e, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey("10.9.0.2")]) } - if len(r.added) != 1 || r.added[0].Warrant != (Warrant{Ask: "ask-ace", Node: "ace", Address: "10.9.0.2"}) || ans.Signature == "" || ans.Account != "account-ace" || ans.Login != "node.ace" || ans.Signer != "vault-signer" || ans.Certificate != "PEM-serving-a" { + if len(r.added) != 1 || r.added[0].Warrant != (delivery.AddNodeWarrant{Ask: "ask-ace", Node: "ace", Address: "10.9.0.2", Warrant: "ask-ace@2026-11-02T10:00:00Z"}) || + r.added[0].Change.Keys.Digest() != good.KeysDigest() || r.added[0].Change.Decided.Address != "10.9.0.2" || ans.Signature == "" || ans.Account != "account-ace" || ans.Login != "node.ace" || ans.Signer != "vault-signer" || ans.Certificate != "PEM-serving-a" { t.Errorf("placement %+v, answer %+v", r.added, ans) } r.loaded = false diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go index 29c1f65..7ff7953 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go @@ -95,12 +95,9 @@ func (v vaultOver) Signer() (string, error) { // addNodeOver asks mesh-placement to add the node with the issue's warrant (ADR 0587 §7). func addNodeOver(call Call) func(AddNode) error { return func(a AddNode) error { - _, err := call(placementAdd, map[string]any{ - "why": a.Why, - "changes": []any{map[string]any{"op": "add-node", "node": a.Node, "address": a.Address, "keys": a.Keys, "warrant": a.Warrant}}, - }) + _, err := call(placementAdd, map[string]any{"why": a.Why, "changes": []any{a.Change}, "warrant": a.Warrant}) if err != nil { - return fmt.Errorf("mesh-placement did not take the node %s: %w", a.Node, err) + return fmt.Errorf("mesh-placement did not take the node %s: %w", a.Change.Node, err) } return nil }