diff --git a/modules/messenger/README.md b/modules/messenger/README.md index cd851c2d..6ddf21d8 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -38,17 +38,32 @@ channel's client and no channel's secret. - **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would. - **Linking** (`/start` to the bot): a six-digit code is shown, with the account's name and number, on a channel whose words stay private (the desk) for ten minutes; the operator sends it to the bot, and the - account is linked. It acknowledges at once and approves after an hour. At most three links are asked of a - kind in an hour. Every link and unlink is said on every channel. + account is linked. It acknowledges at once and approves after an hour. **The code is shown now or never**: + while the desk cannot show it (its notifications paused, nobody there), no link is begun and no code waits + in a queue. At most three links an hour per account, and ten per kind, counted in the router's state. Every + link and unlink is said on every channel. An account unlinked less than a day ago is not linked again by a + code; one account per kind is checked again as a link is made. - **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within - a day of an unlink waits for the replaced account's approval**, asked of it by name. + a day of an unlink waits for the replaced account's approval**, asked of it by name. Each unlink or relink + question binds the account it is about, so its approval never acts on another account linked meanwhile. + **An account still in its first hour**, which cannot approve yet, is unlinked from the desk: when another + account sends `/start`, the desk is asked whether to unlink it. - **When that account is lost with its phone**, root at the router's machine leaves a file in this module's - state directory — `unlink.`, or `allow-link.` for a link that waits — and the router does it, - announced as done at the machine. A file not made by root is refused and said. + state directory — `unlink.`, or `allow-link.` for a link that waits, a regular file owned by + uid 0 — and the router does it within the minute, said on every channel with what was done, and every open + question of the router's own ends. A file not made by root is refused and said. - **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks - for approval are not offered on it. While the controller says an agent can become root without a person - where the router or the channel runs (`agent-root`), no approval is offered or taken there. + for approval are not offered on it. **An approval rests on the controller's positive word**, asked of its + verb `root-free` for the router's machine and the channel's, live when an answer arrives (a word at most + 30 s old renders the buttons): only a machine it says is free carries an approval. A router that does not + know its machine, a controller that does not answer, a machine its answer leaves out, or a word on the + channel kinds older than two minutes all fail closed. +- **An ask that offers an approval is answered only where it could be approved**, whichever option is + chosen: the desk, which proves nothing of who clicked, is not offered it, and an acknowledgement from a + fresh link does not end it. An asker wanting an acknowledgement at the desk asks it alone. +- **Each ask's message says who asks** (as the bus names it), what each answer does, and what happens if + nobody answers. A warrant is published under a message id nobody can foresee. - **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the machine's runtime (ADR 0259 §8): its state and its bus credential are that account's. diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index ce194024..f14a6903 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -17,6 +18,8 @@ import ( // memAsks is the router's state in memory, with the compare-and-set the bus gives. type memAsks struct { + mu sync.Mutex + asked map[string][]time.Time asks map[string]AskRecord revs map[string]uint64 ids map[string]Identity @@ -27,7 +30,7 @@ type memAsks struct { func newMemAsks() *memAsks { return &memAsks{asks: map[string]AskRecord{}, revs: map[string]uint64{}, ids: map[string]Identity{}, - links: map[string]PendingLink{}, unlinked: map[string]Unlinked{}} + links: map[string]PendingLink{}, unlinked: map[string]Unlinked{}, asked: map[string][]time.Time{}} } func clone(r AskRecord) AskRecord { @@ -38,6 +41,8 @@ func clone(r AskRecord) AskRecord { } func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() r, ok := m.asks[k] if !ok { return nil, 0, nil @@ -46,6 +51,8 @@ func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) { return &c, m.revs[k], nil } func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() if _, ok := m.asks[k]; ok { return 0, ErrChanged } @@ -54,6 +61,8 @@ func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) { return m.rev, nil } func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() if m.revs[k] != rev { return 0, ErrChanged } @@ -62,6 +71,8 @@ func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) { return m.rev, nil } func (m *memAsks) AskKeys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() var out []string for k := range m.asks { out = append(out, k) @@ -69,15 +80,29 @@ func (m *memAsks) AskKeys() ([]string, error) { return out, nil } func (m *memAsks) GetIdentity(k string) (*Identity, error) { + m.mu.Lock() + defer m.mu.Unlock() id, ok := m.ids[k] if !ok { return nil, nil } return &id, nil } -func (m *memAsks) PutIdentity(k string, id Identity) error { m.ids[k] = id; return nil } -func (m *memAsks) DeleteIdentity(k string) error { delete(m.ids, k); return nil } +func (m *memAsks) PutIdentity(k string, id Identity) error { + m.mu.Lock() + defer m.mu.Unlock() + m.ids[k] = id + return nil +} +func (m *memAsks) DeleteIdentity(k string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.ids, k) + return nil +} func (m *memAsks) IdentityKeys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() var out []string for k := range m.ids { out = append(out, k) @@ -85,22 +110,55 @@ func (m *memAsks) IdentityKeys() ([]string, error) { return out, nil } func (m *memAsks) GetLink(kind string) (*PendingLink, error) { + m.mu.Lock() + defer m.mu.Unlock() l, ok := m.links[kind] if !ok { return nil, nil } return &l, nil } -func (m *memAsks) PutLink(kind string, l PendingLink) error { m.links[kind] = l; return nil } -func (m *memAsks) DeleteLink(kind string) error { delete(m.links, kind); return nil } +func (m *memAsks) PutLink(kind string, l PendingLink) error { + m.mu.Lock() + defer m.mu.Unlock() + m.links[kind] = l + return nil +} +func (m *memAsks) DeleteLink(kind string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.links, kind) + return nil +} func (m *memAsks) GetUnlinked(kind string) (*Unlinked, error) { + m.mu.Lock() + defer m.mu.Unlock() u, ok := m.unlinked[kind] if !ok { return nil, nil } return &u, nil } -func (m *memAsks) PutUnlinked(kind string, u Unlinked) error { m.unlinked[kind] = u; return nil } +func (m *memAsks) PutUnlinked(kind string, u Unlinked) error { + m.mu.Lock() + defer m.mu.Unlock() + m.unlinked[kind] = u + return nil +} +func (m *memAsks) GetLinksAsked(key string) ([]time.Time, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]time.Time(nil), m.asked[key]...), nil +} +func (m *memAsks) PutLinksAsked(key string, times []time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.asked[key] = append([]time.Time(nil), times...) + return nil +} + +// rootFreeOf is each test router's machines said not root-free, by why. +var rootFreeOf = map[*Holder]map[string]string{} // told is what the router published to askers, by subject. type told struct{ by map[string][]asks.Warrant } @@ -129,6 +187,18 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA bench.Stand("telegram", Standing{Ready: true, At: c.now()}) bench.Stand("desktop", Standing{Ready: true, At: c.now()}) h.Bench, h.Asks = bench, store + h.Node = "anchor" + // The controller's word on root, as its verb root-free answers it: free unless a test says otherwise. + notFree := map[string]string{} + rootFreeOf[h] = notFree + h.RootFree = func(machines []string) (map[string]RootVerdict, error) { + out := map[string]RootVerdict{} + for _, m := range machines { + why, no := notFree[m] + out[m] = RootVerdict{Machine: m, Free: !no, Why: why} + } + return out, nil + } h.Zone = func() *time.Location { return time.UTC } h.Publish = func(subject string, body any, id string) error { if wt, ok := body.(asks.Warrant); ok { @@ -141,13 +211,15 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA // stand keeps the channels' word fresh as the clock moves. func stand(h *Holder, c *clock) { + // The controller's word on the kinds, read again as the router does every half minute. + h.Bench.SetKinds(h.Bench.Kinds()) h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()}) h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()}) } // bound is what an option of the held delivery's ask binds: the asker's act, digested. func bound(verb string) string { - d, err := asks.ActDigest(map[string]string{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"}) + d, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"}) if err != nil { panic(err) } @@ -200,6 +272,17 @@ func labelsOf(m Message) string { return strings.Join(out, ", ") } +// ackAsk is an ask of acknowledgements alone: silencing the held delivery. +func ackAsk(c *clock, id string) asks.Ask { + a := heldAsk(c, id) + a.Options = a.Options[2:] + a.Explanation = "Needs you: silence it, or leave it. A delivery of hq has been held for 36 hours." + return a +} + +// An ask that offers an approval is answered only where it could be approved, whichever option is chosen (the +// review of 2026-10-09): the desk, which proves nothing of who clicked, is offered none of it. An ask of +// acknowledgements alone is offered on every kind that carries a choice. func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) { h, tg, dt, c, store, _ := newRouter(t) link(h, store, c, "42", c.now().Add(-2*time.Hour)) @@ -207,26 +290,27 @@ func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) { a.Urgent = true submit(t, h, "mesh-delivery", a) settle(h, c) - if len(tg.sends) != 1 || len(dt.sends) != 1 { + if len(tg.sends) != 1 || len(dt.sends) != 0 { t.Fatalf("telegram %d, desk %d", len(tg.sends), len(dt.sends)) } if got := labelsOf(tg.sends[0]); got != "Release, Stop, Silence for a week" { t.Errorf("telegram offers %q", got) } - // The desk proves nothing about who clicked: acknowledgements only, and the words say where the rest go. - if got := labelsOf(dt.sends[0]); got != "Silence for a week" { - t.Errorf("the desk offers %q", got) - } - if !strings.Contains(dt.sends[0].Body, "Release or Stop: answer on Telegram, or from the mesh MCP server.") { - t.Errorf("the desk does not say where to answer: %q", dt.sends[0].Body) - } - if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 4 { + if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 3 { t.Errorf("recorded %+v", rec) } + b := ackAsk(c, "d-2") + b.Urgent = true + b.About = "other" + submit(t, h, "mesh-delivery", b) + settle(h, c) + if len(dt.sends) != 1 || labelsOf(dt.sends[0]) != "Silence for a week" { + t.Fatalf("the desk was not offered an acknowledgement: %+v", dt.sends) + } } func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { - h, tg, dt, c, _, _ := newRouter(t) + h, tg, dt, c, _, w := newRouter(t) submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) settle(h, c) // Telegram says only to the account linked as the operator: none linked, it is not used at all. @@ -236,17 +320,18 @@ func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { if err := h.Bench.Ready("telegram"); err == nil || !strings.Contains(err.Error(), "send /start to the bot") { t.Errorf("an unlinked Telegram is ready: %v", err) } - if len(dt.sends) == 0 { - t.Fatal("nothing was said on the desk") + // The ask offers an approval no channel can carry: refused to its asker, in words. + if got := w.of("mesh-delivery"); len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || + !strings.Contains(got[0].Words, "no channel can carry") { + t.Fatalf("not refused: %+v", got) } - m := dt.sends[0] - if got := labelsOf(m); got != "Silence for a week" || !strings.Contains(m.Body, "Release or Stop: from the mesh MCP server.") { - t.Errorf("the desk without a link offers %q and says %q", got, m.Body) + if len(dt.sends) != 0 { + t.Errorf("the desk was offered an ask with an approval: %+v", dt.sends) } } func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { - h, tg, dt, c, store, w := newRouter(t) + h, tg, _, c, store, w := newRouter(t) link(h, store, c, "42", c.now().Add(-2*time.Hour)) asked := heldAsk(c, "d-1") submit(t, h, "mesh-delivery", asked) @@ -275,7 +360,7 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { t.Errorf("the record: %+v", rec) } // Every copy says how it ended, its buttons gone. - for _, ch := range []*fakeChannel{tg, dt} { + for _, ch := range []*fakeChannel{tg} { var edited Message for _, m := range ch.edits { edited = m @@ -316,7 +401,7 @@ func TestEveryAnswerThatIsNotTheOperatorsIsRefusedAndSaid(t *testing.T) { }{ "an account not linked": {"telegram", "666", true, 2 * time.Hour, "Release", 0, "not linked as the operator"}, "a sender the service did not say": {"telegram", "42", false, 2 * time.Hour, "Release", 0, "did not authenticate"}, - "a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can approve from"}, + "a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can answer this question from"}, "an ask that expired": {"telegram", "42", true, 2 * time.Hour, "Release", 3 * time.Hour, "expired"}, "a ticket shown on another kind": {"desktop", "g14", true, 2 * time.Hour, "Release", 0, "not offered here"}, } { @@ -373,10 +458,10 @@ func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) { if out, err := h.Chosen("telegram", late); err != nil || out != "chosen" { t.Fatalf("refused at 60 minutes: %s %v", out, err) } - // And an acknowledgement is taken at once from a fresh link. + // An acknowledgement is taken at once from a fresh link, on an ask of acknowledgements alone. h2, tg2, _, c2, store2, w2 := newRouter(t) link(h2, store2, c2, "42", c2.now()) - submit(t, h2, "mesh-delivery", heldAsk(c2, "d-2")) + submit(t, h2, "mesh-delivery", ackAsk(c2, "d-2")) settle(h2, c2) silence = ticketFor(tg2.sends[0], "Silence for a week") if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" { @@ -388,9 +473,40 @@ func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) { _ = w } +// An acknowledgement does not end an ask that waits for an approval from where an approval is not proven +// (the review of 2026-10-09): a fresh link's Silence on the held delivery's ask is refused, and the ask stays +// open for Release or Stop once the hour has passed. +func TestAnAcknowledgementDoesNotEndAnAskThatWaitsForAnApproval(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now()) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + silence := ticketFor(tg.sends[0], "Silence for a week") + if out, err := h.Chosen("telegram", choiceOf(silence, "42", c.now())); err == nil || out != "refused" { + t.Fatalf("a fresh link's acknowledgement ended an ask that offers an approval: %s %v", out, err) + } + if len(w.of("mesh-delivery")) != 0 || store.asks["mesh-delivery.d-1"].State != StateOpen { + t.Fatal("the ask ended") + } + // Nor while the controller does not say root is free there. + c.pass(time.Hour) + stand(h, c) + rootFreeOf[h]["anchor"] = "not measured" + again := choiceOf(silence, "42", c.now()) + again.ID = "again" + if out, _ := h.Chosen("telegram", again); out != "refused" || store.asks["mesh-delivery.d-1"].State != StateOpen { + t.Fatalf("an acknowledgement ended the ask while root is not free: %s", out) + } + delete(rootFreeOf[h], "anchor") + release := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now()) + if out, err := h.Chosen("telegram", release); err != nil || out != "chosen" { + t.Fatalf("the approval after the hour: %s %v", out, err) + } +} + func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) { h, _, dt, c, _, w := newRouter(t) - submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + submit(t, h, "mesh-delivery", ackAsk(c, "d-1")) settle(h, c) silence := ticketFor(dt.sends[0], "Silence for a week") ch := choiceOf(silence, "g14", c.now()) @@ -415,9 +531,20 @@ func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) { store2.asks["mesh-delivery.d-1"] = rec h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"]) if out, err := h2.Chosen("desktop", choiceOf(release, "g14", c2.now())); err == nil || out != "refused" || - !strings.Contains(err.Error(), "can only acknowledge") { + !strings.Contains(err.Error(), "cannot answer a question that asks for an approval") { t.Errorf("the desk approved: %s %v", out, err) } + // Nor does an acknowledgement forged onto the desk end that ask. + silence = ticketFor(tg2.sends[0], "Silence for a week") + tk = rec.Tickets[silence] + tk.Kind = "desktop" + rec.Tickets[silence] = tk + store2.asks["mesh-delivery.d-1"] = rec + h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"]) + if out, _ := h2.Chosen("desktop", choiceOf(silence, "g14", c2.now())); out != "refused" || + store2.asks["mesh-delivery.d-1"].State != StateOpen { + t.Errorf("a desk click ended an ask that waits for an approval: %s", out) + } } func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) { @@ -429,7 +556,7 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) { carrying.Explanation = "Needs you: see /etc/passwd on the machine." submit(t, h, "mesh-delivery", carrying) for i := 3; i <= 6; i++ { - a := heldAsk(c, "d-"+string(rune('0'+i))) + a := ackAsk(c, "d-"+string(rune('0'+i))) a.About = "about-" + string(rune('0'+i)) submit(t, h, "mesh-delivery", a) } @@ -483,10 +610,10 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) { func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T) { h, _, _, c, store, w := newRouter(t) - a := heldAsk(c, "d-1") + a := ackAsk(c, "d-1") a.About = "delivery.hq" submit(t, h, "mesh-delivery", a) - b := heldAsk(c, "d-2") + b := ackAsk(c, "d-2") b.About = "delivery.hq" submit(t, h, "mesh-delivery", b) if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) { @@ -500,7 +627,7 @@ func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T t.Errorf("%+v", got) } // Another asker cannot cancel it: its cancel names its own ask, under its own name. - submit(t, h, "mesh-delivery", heldAsk(c, "d-3")) + submit(t, h, "mesh-delivery", ackAsk(c, "d-3")) _ = h.TakeAsk("cancel", "mesh-controller", json.RawMessage(`{"id":"d-3"}`)) if store.asks["mesh-delivery.d-3"].State != StateOpen { t.Error("another asker's cancel ended it") @@ -514,7 +641,7 @@ func TestAnAskAboutAConditionIsAnsweredOnItsMessage(t *testing.T) { k.Raised = c.now() h.Condition(EventRaised, k) settle(h, c) - a := heldAsk(c, "d-1") + a := ackAsk(c, "d-1") a.About = "delivery.hq.held" submit(t, h, "mesh-controller", a) settle(h, c) @@ -703,29 +830,61 @@ func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) { } } -// Root at the router's machine unlinks, or allows a waiting link, by a file only root makes. -func TestBreakGlassIsAFileRootMade(t *testing.T) { - h, _, dt, c, store, _ := newRouter(t) +// Root at the router's machine unlinks, or allows a waiting link, by a file only root makes. It acts from the +// router's minute (Tick), which holds the work: the review of 2026-10-09 found the minute took the work again +// there and hung for ever the first time root used it. It is said on every channel, and the router's own open +// questions end. +func TestBreakGlassIsAFileRootMadeAndTheMinuteGoesOn(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) link(h, store, c, "42", c.now().Add(-2*time.Hour)) h.StateDir = t.TempDir() + tick := func() { + t.Helper() + done := make(chan struct{}) + go func() { h.Tick(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the router's minute hung on a break-glass file") + } + } if err := os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600); err != nil { t.Fatal(err) } - h.BreakGlass() + tick() if len(store.ids) != 1 || !strings.Contains(dt.sends[len(dt.sends)-1].Title, "was refused") { t.Fatalf("a file not made by root unlinked: %v", store.ids) } + // An open question of the router's own: root's act ends it. + if _, err := h.Unlink("telegram"); err != nil { + t.Fatal(err) + } was := fileOwner t.Cleanup(func() { fileOwner = was }) fileOwner = func(os.FileInfo) uint32 { return 0 } _ = os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600) - h.BreakGlass() + tick() if len(store.ids) != 0 { t.Fatal("root's file did not unlink") } if _, err := os.Stat(filepath.Join(h.StateDir, "unlink.telegram")); err == nil { t.Error("the file was left to act again") } + for _, ch := range []*fakeChannel{tg, dt} { + said := false + for _, m := range ch.sends { + said = said || (m.Title == "Urgent: Root changed who is you" || strings.Contains(m.Title, "Root changed who is you")) && + strings.Contains(m.Body, "was unlinked") && strings.Contains(m.Body, "1 open question") + } + if !said && ch == dt { + t.Errorf("%s was not told what root did: %+v", ch.name, ch.sends[len(ch.sends)-1]) + } + } + for k, rec := range store.asks { + if rec.Asker == SelfAsker && rec.State == StateOpen { + t.Errorf("the router's own question %s is still open after root decided", k) + } + } } // While an agent can become root where the router or the channel runs, nothing proven there approves. @@ -736,12 +895,9 @@ func TestNoApprovalWhileAnAgentCanBecomeRootThere(t *testing.T) { submit(t, h, "mesh-delivery", asked) settle(h, c) release := ticketFor(tg.sends[0], "Release") - h.mu.Lock() - h.open["machine.anchor.agent-root"] = &Record{Key: "machine.anchor.agent-root", Kind: KindAgentRoot, Severity: Urgent, - Origin: "condition", Raised: c.now()} - h.mu.Unlock() + rootFreeOf[h]["anchor"] = "the agent account is not judged" if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" || - !strings.Contains(err.Error(), "can become root on anchor") { + !strings.Contains(err.Error(), "may become root on anchor") { t.Fatalf("approved while an agent can become root there: %s %v", out, err) } if len(w.of("mesh-delivery")) != 0 { @@ -840,9 +996,8 @@ func codeOf(t *testing.T, m Message) string { return "" } -// An ask's message goes alone even when only one of its answers can be given on a channel: folded into a burst's -// digest, its one button was lost (found by the lab's proof, mesh-lab asks/, with approval held by an open -// agent-root condition and that condition's message in the same burst). +// An ask's message goes alone even when it offers one answer: folded into a burst's digest, its one button was +// lost (found by the lab's proof, mesh-lab asks/, with a condition's message in the same burst). func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) { h, tg, _, c, store, _ := newRouter(t) link(h, store, c, "42", c.now().Add(-2*time.Hour)) @@ -850,15 +1005,13 @@ func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) { // Something was just said on the channel, so what follows waits for the burst. submit(t, h, "mesh-delivery", heldAsk(c, "d-0")) settle(h, c) - h.mu.Lock() - h.open["machine.anchor.agent-root"] = &Record{Key: "machine.anchor.agent-root", Kind: KindAgentRoot, Severity: Urgent, - Origin: "condition", Raised: c.now()} - h.mu.Unlock() k := cond("machine.anchor.disk", Urgent, "the disk on anchor is full") k.Raised = c.now() h.Condition(EventRaised, k) before := len(tg.sends) - submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + one := heldAsk(c, "d-1") + one.Options = one.Options[2:] // Silence alone: one answer + submit(t, h, "mesh-delivery", one) settle(h, c) var asked *Message for i := range tg.sends[before:] { @@ -870,7 +1023,7 @@ func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) { if asked == nil { t.Fatalf("the ask's one answer was not offered on Telegram: %+v", tg.sends[before:]) } - if ticketFor(*asked, "Release") != "" { - t.Errorf("an approval was offered while an agent can become root there: %q", labelsOf(*asked)) + if strings.Contains(asked.Body, "disk on anchor") { + t.Errorf("the ask's message was folded with another: %q", asked.Body) } } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 3bb56d91..fa986f86 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -93,6 +93,9 @@ type AskStore interface { DeleteLink(kind string) error GetUnlinked(kind string) (*Unlinked, error) PutUnlinked(kind string, u Unlinked) error + // GetLinksAsked and PutLinksAsked keep when links were asked, by account and by kind (allowLink). + GetLinksAsked(key string) ([]time.Time, error) + PutLinksAsked(key string, times []time.Time) error } type askEntry struct { @@ -161,7 +164,8 @@ func (h *Holder) openAsk(r *Record) *AskRecord { } // carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for -// approve verifying their sender with an account of the operator's linked. +// approve verifying their sender with an account of the operator's linked, where the controller says now +// that no agent can become root (rootFree, asked live). func (h *Holder) carriers(level asks.Level) []string { if h.Bench == nil { return nil @@ -176,7 +180,7 @@ func (h *Holder) carriers(level asks.Level) []string { case asks.Acknowledge: out = append(out, k.Kind) case asks.Approve: - if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFreeFor(k) == "" { + if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFree(k, false) == "" { out = append(out, k.Kind) } } @@ -185,32 +189,155 @@ func (h *Holder) carriers(level asks.Level) []string { return out } -// rootFreeFor is why a kind's proof is not believed now, or "": an agent can become root without a person -// where the router or the kind's holder runs, which the controller's self-check says as an open condition -// (novox/hq ADR 0259 §8, rule 2). -func (h *Holder) rootFreeFor(k KindInfo) string { +// RootVerdict is the controller's word on one machine (its verb root-free, novox/hq ADR 0259 §8): free only +// when it measured, recently, that no agent can become root there without a person. +type RootVerdict struct { + Machine string `json:"machine"` + Free bool `json:"free"` + Why string `json:"why"` + Judged string `json:"judged,omitempty"` +} + +// RootFreeVerb is the controller's verb the router asks, as this module, which machines are root-free. +const RootFreeVerb = "seat:" + ControllerSeat + ".root-free" + +// RootFreeWithin is how long the router waits for the controller's word; RootFreeKept how long a word is +// used to render a message's buttons. An answer is always judged on a word asked then. +const ( + RootFreeWithin = 10 * time.Second + RootFreeKept = 30 * time.Second +) + +// askRootFree asks the controller about machines, failing on no answer in time. +func (h *Holder) askRootFree(machines []string) (map[string]RootVerdict, error) { + if h.RootFree != nil { + return h.RootFree(machines) + } + if h.Ask == nil { + return nil, errors.New("this router cannot ask the controller") + } + type answer struct { + raw json.RawMessage + err error + } + done := make(chan answer, 1) + go func() { + raw, err := h.Ask(RootFreeVerb, map[string]any{"machines": machines}) + done <- answer{raw, err} + }() + var a answer + select { + case a = <-done: + case <-time.After(RootFreeWithin): + return nil, fmt.Errorf("the controller did not answer within %s", RootFreeWithin) + } + if a.err != nil { + return nil, a.err + } + var body struct { + Machines []RootVerdict `json:"machines"` + } + if err := json.Unmarshal(a.raw, &body); err != nil { + return nil, fmt.Errorf("the controller's answer could not be read: %v", err) + } + out := map[string]RootVerdict{} + for _, v := range body.Machines { + out[v.Machine] = v + } + return out, nil +} + +// rootFree is why a kind's proof is not believed now, or "" when the controller says, now, that no agent can +// become root without a person on the router's machine and on the kind's (novox/hq ADR 0259 §8, rule 2, as +// the review of 2026-10-09 asked: a positive, recent pass, never the absence of a condition). Anything else +// fails: a router that does not know its machine, a kind whose machine is not said, a controller that does +// not answer, a machine missing from its answer, a word that is not free. cached allows a word up to +// RootFreeKept old, for rendering; an answer is always judged with cached false. +func (h *Holder) rootFree(k KindInfo, cached bool) string { + if h.Node == "" { + return "this router does not know which machine it runs on, so it cannot ask whether an agent can become root there" + } + if k.Node == "" { + return "the mesh has not said which machine " + Display(k.Kind) + " runs on" + } + machines := []string{h.Node} + if k.Node != h.Node { + machines = append(machines, k.Node) + } + now := h.Now() + key := strings.Join(machines, ",") h.mu.Lock() - defer h.mu.Unlock() - for _, r := range h.open { - if r.Kind != KindAgentRoot || !r.Cleared.IsZero() { - continue + kept, ok := h.rootWord[key] + h.mu.Unlock() + var verdicts map[string]RootVerdict + if cached && ok && now.Sub(kept.at) < RootFreeKept && now.Sub(kept.at) >= 0 { + verdicts = kept.verdicts + } else { + got, err := h.askRootFree(machines) + if err != nil { + return "whether an agent can become root where it runs could not be asked: " + err.Error() } - machine := strings.TrimPrefix(strings.TrimSuffix(r.Key, "."+KindAgentRoot), "machine.") - if machine == k.Node || (h.Node != "" && machine == h.Node) { - return "a program working for you can become root on " + machine + " without asking you, so an answer " + - "proven there proves nothing until that changes" + verdicts = got + h.mu.Lock() + if h.rootWord == nil { + h.rootWord = map[string]rootWord{} + } + h.rootWord[key] = rootWord{at: now, verdicts: got} + h.mu.Unlock() + } + for _, m := range machines { + v, said := verdicts[m] + switch { + case !said: + return "the controller said nothing of whether an agent can become root on " + m + case !v.Free: + why := v.Why + if why == "" { + why = "it is not measured that it cannot" + } + return "a program working for you may become root on " + m + " without asking you (" + why + + "), so an answer proven there proves nothing until that changes" } } return "" } +// rootWord is the controller's last word on a set of machines, and when it was asked. +type rootWord struct { + at time.Time + verdicts map[string]RootVerdict +} + +// approveHolds is why an answer on a kind could not approve now, or "": the kind verifies its sender, an +// account is linked there, and the controller says no agent can become root where it runs. +func (h *Holder) approveHolds(kind string, cached bool) string { + info, held := h.Bench.Kind(kind) + switch { + case !held: + return "this channel is not one the mesh holds for answers" + case !h.Bench.Promises(kind, "verified-sender"): + return Display(kind) + " cannot prove who answered" + case !h.linked(kind): + return "no " + Display(kind) + " account is linked as you" + } + return h.rootFree(info, cached) +} + +// answerable is whether an option of an ask is answered on a kind, by the ask's highest level: an ask that +// offers an approval is answered only where it could be approved, whichever of its options is chosen, so an +// acknowledgement from where approval is not proven never ends a question that waits for one (the review of +// 2026-10-09). +func answerLevel(a asks.Ask) asks.Level { + if a.Highest().Rank() > asks.Acknowledge.Rank() { + return a.Highest() + } + return asks.Acknowledge +} + // ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which // raises the conditions. const ConditionOwner = "mesh-controller" -// KindAgentRoot is the controller's condition while an agent can become root without a person on a machine. -const KindAgentRoot = "agent-root" - // TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus // grants that module alone. An error is returned only when the ask could not be recorded, so the queue // offers it again; a refusal is said to the asker and the work is taken. @@ -274,8 +401,10 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { return nil } rec := AskRecord{Asker: asker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}} - for _, o := range a.Options { - for _, kind := range h.carriers(o.Level) { + // Every option on the kinds that carry the ask's highest level: an ask with an approval is answered only + // where it could be approved (answerLevel). + for _, kind := range h.carriers(answerLevel(a)) { + for _, o := range a.Options { rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: kind} } } @@ -400,7 +529,10 @@ func (h *Holder) tell(w asks.Warrant) error { if h.Publish == nil { return errors.New("this router cannot reach the bus") } - err := h.Publish(asks.DecidedSubject(w.Asker), w, askKey(w.Asker, w.Ask)+"."+string(w.Outcome)) + // A new message id each time (the review of 2026-10-09): an id anyone could foresee lets a publisher on + // the same stream take it first, and the bus would drop the warrant as a duplicate. The asker acts on its + // own record of the ask once, however many times it hears a warrant. + err := h.Publish(asks.DecidedSubject(w.Asker), w, "warrant."+ticket()) if err != nil { h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err) } @@ -547,6 +679,16 @@ func (h *Holder) sweepAsks(now time.Time) { // offers are an open ask's buttons on one kind: each option offered there, by its ticket. func (h *Holder) offers(rec *AskRecord, kind string) []Choice { var out []Choice + if answerLevel(rec.Ask) != asks.Acknowledge && rec.Asker != SelfAsker { + // Rendered only while approval holds there now, by the controller's recent word. + if h.approveHolds(kind, true) != "" { + return nil + } + } else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != asks.Acknowledge { + if info, held := h.Bench.Kind(kind); !held || h.rootFree(info, true) != "" { + return nil + } + } for _, o := range rec.Ask.Options { for t, tk := range rec.Tickets { if tk.Kind == kind && tk.Option == o.ID && !tk.Used { @@ -657,23 +799,36 @@ func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { } person.Verified = "user id verified" } else { + if rec.Only != nil && rec.Only.Kind != kind { + return h.refuseChoice(kind, c, "this question was put to another channel") + } person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity } + // Judged at the ask's highest level, whichever option was chosen (answerLevel): an acknowledgement on an + // ask that offers an approval needs what the approval needs, so it never ends that ask from where an + // approval is not proven. + level := o.Level + if answerLevel(rec.Ask).Rank() > level.Rank() { + level = answerLevel(rec.Ask) + } proofs := []string{} - switch o.Level { + switch level { case asks.Acknowledge: case asks.Approve: if !verifies { - return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it can only acknowledge") + return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it cannot answer a question "+ + "that asks for an approval") } if now.Before(id.ApproveFrom) { return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+ - " and can approve from "+id.ApproveFrom.In(h.zone()).Format("15:04")) + " and can answer this question from "+id.ApproveFrom.In(h.zone()).Format("15:04")) } - if why := h.rootFreeFor(info); why != "" { + if why := h.rootFree(info, false); why != "" { return h.refuseChoice(kind, c, why) } - proofs = append(proofs, "P1") + if o.Level == asks.Approve { + proofs = append(proofs, "P1") + } default: return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet") } @@ -731,7 +886,8 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) { if h.Publish == nil { return "", errors.New("this router cannot reach the bus") } - out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To} + out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To, + Secret: m.Secret} if out.To == "" && h.Bench != nil && h.Bench.To != nil { out.To = h.Bench.To(kind) } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 461ad264..45ec112d 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -244,6 +244,8 @@ type Holder struct { Emit func(event string, body any) error // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. Ask Asker + // RootFree asks the controller's verb root-free about machines; nil asks it through Ask (askRootFree). + RootFree func(machines []string) (map[string]RootVerdict, error) // Zone is the operator's time zone, which every time in a message is said in; nil is this machine's. Zone func() *time.Location @@ -279,9 +281,10 @@ type Holder struct { // asks are the asks this router holds, by `.`, and tickets each ask's tickets to it. asks map[string]*askEntry tickets map[string]string - // linksAsked are when links were asked, by kind, for the hourly bound; lastUnlink the last unlink of each. - linksAsked map[string][]time.Time + // lastUnlink is the last unlink of each kind. lastUnlink map[string]Unlinked + // rootWord is the controller's last word on whether an agent can become root, by the machines asked. + rootWord map[string]rootWord } func (h *Holder) init() { @@ -563,7 +566,7 @@ func (h *Holder) Tick() { h.mu.Unlock() sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) }) h.sweepAsks(now) - h.BreakGlass() + h.breakGlass() for _, r := range recs { switch { case !r.Cleared.IsZero(): @@ -640,6 +643,13 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { return m } if rec := h.openAsk(r); rec != nil { + // Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does, + // and what happens if nobody answers (the review of 2026-10-09). + for _, line := range h.askLines(rec) { + if _, ok := Check(line); ok { + m.Body = strings.TrimSpace(m.Body + "\n" + line) + } + } if line := h.elsewhere(rec, kind); line != "" { if _, ok := Check(line); ok { m.Body = strings.TrimSpace(m.Body + "\n" + line) @@ -650,6 +660,24 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { return m } +// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry. +func (h *Holder) askLines(rec *AskRecord) []string { + asker := rec.Asker + if asker == SelfAsker { + asker = "the router itself" + } + lines := []string{"Asked by " + asker + "."} + for _, o := range rec.Ask.Options { + if o.Does != "" { + lines = append(lines, o.Label+": "+o.Does+".") + } + } + if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" { + lines = append(lines, "If nobody answers by "+rec.Ask.Expires.In(h.zone()).Format("15:04")+": "+rec.Ask.OnExpiry+".") + } + return lines +} + // words is a record's message under the content rule: refused, it is composed again with less of it, // until what remains may leave. The refusal is recorded and stated once per record. func (h *Holder) words(r *Record, what string) Message { diff --git a/modules/messenger/cmd/messenger/link.go b/modules/messenger/cmd/messenger/link.go index 959d11b9..24a1720f 100644 --- a/modules/messenger/cmd/messenger/link.go +++ b/modules/messenger/cmd/messenger/link.go @@ -95,8 +95,93 @@ func (h *Holder) identitiesOf(kind string) []Identity { func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 } -// LinksAnHour is how many links may be asked of one kind in an hour. -const LinksAnHour = 3 +// LinksAnHour is how many links one account may ask in an hour, and KindLinksAnHour how many all accounts +// together may ask of one kind: kept in the router's state, so a restart forgets neither (the review of +// 2026-10-09). Per account, so a stranger's tries do not stop the operator's. +const ( + LinksAnHour = 3 + KindLinksAnHour = 10 +) + +// allowLink counts a link asked by an account on a kind, and says why not when either bound is reached, or +// the count cannot be kept: an uncounted link is not begun. +func (h *Holder) allowLink(kind, identity string, now time.Time) string { + for _, b := range []struct { + key string + most int + words string + }{ + {"asked." + kind + "." + kvKey(identity), LinksAnHour, fmt.Sprintf("This account asked to be linked %d times this hour. Try again in an hour.", LinksAnHour)}, + {"asked." + kind, KindLinksAnHour, fmt.Sprintf("Links were asked %d times this hour on %s. Try again in an hour.", KindLinksAnHour, Display(kind))}, + } { + times, err := h.Asks.GetLinksAsked(b.key) + if err != nil { + return "How often links were asked cannot be read, so none is begun: " + err.Error() + } + var recent []time.Time + for _, t := range times { + if now.Sub(t) < time.Hour && now.Sub(t) >= 0 { + recent = append(recent, t) + } + } + if len(recent) >= b.most { + return b.words + } + } + for _, key := range []string{"asked." + kind + "." + kvKey(identity), "asked." + kind} { + times, _ := h.Asks.GetLinksAsked(key) + var recent []time.Time + for _, t := range times { + if now.Sub(t) < time.Hour && now.Sub(t) >= 0 { + recent = append(recent, t) + } + } + if err := h.Asks.PutLinksAsked(key, append(recent, now)); err != nil { + return "How often links were asked cannot be kept, so none is begun: " + err.Error() + } + } + return "" +} + +// offerDeskUnlink asks at the desk whether an account still in its first hour is unlinked: it cannot approve +// yet, so whoever is at the desk may undo it (ADR 0259 §7, the review of 2026-10-09). One such question per +// account at a time. Answers whether it was asked. +func (h *Holder) offerDeskUnlink(id Identity) bool { + desk := "" + for _, k := range h.Bench.Kinds() { + if k.Kind != id.Kind && h.Bench.Promises(k.Kind, "private") && !h.Bench.Promises(k.Kind, "verified-sender") && + h.Bench.Ready(k.Kind) == nil { + desk = k.Kind + break + } + } + if desk == "" { + return false + } + h.mu.Lock() + for _, e := range h.asks { + if e.rec.Asker == SelfAsker && e.rec.State == StateOpen && e.rec.Act != nil && e.rec.Act.Do == "unlink" && + e.rec.Act.Identity == id.Identity && e.rec.Only != nil && e.rec.Only.Kind == desk { + h.mu.Unlock() + return true + } + } + h.mu.Unlock() + now := h.Now() + until := id.ApproveFrom + a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?", + Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+ + "to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+ + "or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity, + id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")), + Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked", + Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}} + if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil { + h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err) + return false + } + return true +} // Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on // the kind. @@ -122,38 +207,45 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string { if id.Identity == l.Sender.Identity { return reply("Already linked", "This account is already linked as the operator.") } - return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+ - "with that account's own approval, and then this one can be linked.") - } - h.mu.Lock() - if h.linksAsked == nil { - h.linksAsked = map[string][]time.Time{} - } - var recent []time.Time - for _, t := range h.linksAsked[kind] { - if now.Sub(t) < time.Hour { - recent = append(recent, t) + if now.Before(id.ApproveFrom) && h.offerDeskUnlink(id) { + return reply("Another account is linked", "Another account was linked here less than an hour ago. If "+ + "that was not you, unlink it at your desk, where you were just asked, or as root at the router's "+ + "machine; then send /start again.") } + return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+ + "with that account's own approval, or as root at the router's machine, and then this one can be linked.") } - full := len(recent) >= LinksAnHour - if !full { - recent = append(recent, now) + if u := h.recentUnlink(kind); u != nil && u.Identity.Identity == l.Sender.Identity { + // An account unlinked less than a day ago does not come back by a code at once (the review of + // 2026-10-09): whoever unlinked it meant it, and an account taken with a phone must not walk back in. + return reply("Not linked", "This account was unlinked less than a day ago. It can be linked again from "+ + u.At.Add(RelinkWaits).In(h.zone()).Format("2006-01-02 15:04")+", or allowed sooner as root at the router's machine.") } - h.linksAsked[kind] = recent - h.mu.Unlock() - if full { - return reply("Not linked", fmt.Sprintf("Links were asked %d times this hour. Try again in an hour.", LinksAnHour)) + if why := h.allowLink(kind, l.Sender.Identity, now); why != "" { + return reply("Not linked", why) } // A code is shown only where the words stay on the operator's machines: a kind declaring `private`. + // It is shown now or never: a desk that cannot show it now (its notifications paused, nobody there) begins + // no link, so no code waits in a queue to be shown to whoever comes later (the review of 2026-10-09). shows := "" + var whyNot []string for _, k := range h.Bench.Kinds() { - if k.Kind != kind && h.Bench.Promises(k.Kind, "private") && h.Bench.Ready(k.Kind) == nil { - shows = k.Kind - break + if k.Kind == kind || !h.Bench.Promises(k.Kind, "private") { + continue } + if err := h.Bench.Ready(k.Kind); err != nil { + whyNot = append(whyNot, err.Error()) + continue + } + shows = k.Kind + break } if shows == "" { - return reply("Not linked", "No private place can show the code now. Try again when you are at your machine.") + why := "No private place can show the code now." + if len(whyNot) > 0 { + why = "Your desk cannot show the code now: " + strings.Join(whyNot, "; ") + "." + } + return reply("Not linked", why+" Nothing was begun. Try again when you are at your machine.") } code, err := sixDigits() if err != nil { @@ -217,7 +309,16 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer { return no("That code is not right.") } id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display} - if u := h.recentUnlink(kind); u != nil && u.Identity.Identity != p.Identity { + u := h.recentUnlink(kind) + if u != nil && u.Identity.Identity == p.Identity { + _ = h.Asks.DeleteLink(kind) + return no("This account was unlinked less than a day ago, and is not linked again by a code before a day has passed.") + } + if others := h.identitiesOf(kind); len(others) > 0 { + _ = h.Asks.DeleteLink(kind) + return no("Another account was linked here meanwhile. Nothing was linked.") + } + if u != nil { // Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7). p.Confirmed = true p.Expires = now.Add(RelinkWaits) @@ -230,7 +331,7 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer { Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}} prior := u.Identity prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked - if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Link: &id}); err != nil { + if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil { return no("The link could not be asked of the account it replaces: " + err.Error()) } h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you", diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 6ae6fe59..e4d474bd 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -171,7 +171,7 @@ func (askStore) IdentityKeys() ([]string, error) { keys, err := stdio.State("identities").Keys() var out []string for _, k := range keys { - if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") { + if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") && !strings.HasPrefix(k, "asked.") { out = append(out, k) } } @@ -205,6 +205,19 @@ func (askStore) PutUnlinked(kind string, u Unlinked) error { return err } +func (askStore) GetLinksAsked(key string) ([]time.Time, error) { + e, err := stdio.State("identities").Get(key) + if err != nil || e == nil { + return nil, err + } + var times []time.Time + return times, json.Unmarshal(e.Value, ×) +} +func (askStore) PutLinksAsked(key string, times []time.Time) error { + _, err := stdio.State("identities").Put(key, times) + return err +} + // listening is whether condition events reach this holder, in words. type listening struct { mu sync.Mutex diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index f84413e0..6484fc2a 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -84,8 +84,9 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { "intake.choice", "intake.link", "intake.failed", "intake.standing"}) { t.Fatalf("consumes %v", m.Consumes) } - // It performs nothing: the one verb it calls reads the open conditions. - if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions"}) { + // It performs nothing: the verbs it calls read the open conditions and whether root is free. + if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions", + "seat:" + ControllerSeat + ".root-free"}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } var state []string diff --git a/modules/messenger/cmd/messenger/review_test.go b/modules/messenger/cmd/messenger/review_test.go new file mode 100644 index 00000000..827d1f3b --- /dev/null +++ b/modules/messenger/cmd/messenger/review_test.go @@ -0,0 +1,396 @@ +package main + +// The findings of the review of 2026-10-09, each held here by a test that failed before its fix. + +import ( + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// H3: approval rests on the controller's positive, recent word that no agent can become root where the router +// and the channel run (its verb root-free), asked live. Every other answer fails closed. +func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) { + for name, tc := range map[string]struct { + setup func(h *Holder) + want string + }{ + "a router that does not know its machine": {func(h *Holder) { h.Node = "" }, "does not know which machine"}, + "a channel whose machine is not said": {func(h *Holder) { + k, _ := h.Bench.Kind("telegram") + k.Node = "" + h.Bench.SetKinds([]KindInfo{k}) + }, "has not said which machine"}, + "a controller that does not answer": {func(h *Holder) { + h.RootFree = func([]string) (map[string]RootVerdict, error) { return nil, errors.New("no responders") } + }, "could not be asked: no responders"}, + "a machine the answer leaves out": {func(h *Holder) { + h.RootFree = func([]string) (map[string]RootVerdict, error) { return map[string]RootVerdict{}, nil } + }, "said nothing of whether an agent can become root on anchor"}, + "a machine not free": {func(h *Holder) { rootFreeOf[h]["anchor"] = "the agent account is not judged" }, + "may become root on anchor"}, + "the router's own machine not free": {func(h *Holder) { + h.Node = "router-machine" + rootFreeOf[h]["router-machine"] = "execute is served there" + }, "may become root on router-machine"}, + } { + t.Run(name, func(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + release := ticketFor(tg.sends[0], "Release") + if release == "" { + t.Fatal("not offered while root was free") + } + tc.setup(h) + out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())) + if out != "refused" || err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s %v, want refused for %q", out, err, tc.want) + } + if len(w.of("mesh-delivery")) != 0 { + t.Error("a warrant was issued") + } + if got := h.carriers(asks.Approve); len(got) != 0 { + t.Errorf("approval still carried on %v", got) + } + }) + } +} + +// H3: the word is asked of the controller's verb, as this module, and read from its answer. +func TestTheRootWordIsAskedOfTheControllersVerb(t *testing.T) { + h, _, _, _, _, _ := newRouter(t) + h.RootFree = nil + var asked []string + h.Ask = func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + raw, _ := json.Marshal(body) + if !strings.Contains(string(raw), `"machines":["anchor"]`) { + t.Errorf("asked about %s", raw) + } + return json.RawMessage(`{"machines":[{"machine":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil + } + info, _ := h.Bench.Kind("telegram") + if why := h.rootFree(info, false); why != "" { + t.Fatalf("free not read: %s", why) + } + if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free" { + t.Errorf("asked %v", asked) + } + h.Ask = func(string, any) (json.RawMessage, error) { return json.RawMessage(`not json`), nil } + if why := h.rootFree(info, false); !strings.Contains(why, "could not be read") { + t.Errorf("an unreadable answer was a pass: %q", why) + } + h.Ask = nil + if why := h.rootFree(info, false); why == "" { + t.Error("no way to ask was a pass") + } +} + +// H3: a message rendered while root is not free offers no approval; a word kept for rendering is never the +// word an answer is judged on. +func TestAMessageRenderedWhileRootIsNotFreeOffersNoApproval(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + rec := h.openAsk(h.open["ask.mesh-delivery.d-1"]) + if got := h.offers(rec, "telegram"); len(got) != 3 { + t.Fatalf("offers %v", got) + } + rootFreeOf[h]["anchor"] = "not judged" + c.pass(RootFreeKept) + if got := h.offers(rec, "telegram"); len(got) != 0 { + t.Errorf("rendered with an approval while root is not free: %v", got) + } + // Free again, but the kept word is not: the answer asks anew and is taken. + delete(rootFreeOf[h], "anchor") + out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())) + if err != nil || out != "chosen" { + t.Errorf("judged on a kept word: %s %v", out, err) + } +} + +// H3: a capability the controller's word no longer says stays no longer than the word is fresh: a router that +// cannot read the kinds again promises no verified sender. +func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + if !h.Bench.Promises("telegram", "verified-sender") { + t.Fatal("not promised while fresh") + } + c.pass(KindsFresh + time.Second) + h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 { + t.Error("a stale word still promises a verified sender") + } + if !h.Bench.Promises("telegram", "choice") { + t.Error("a stale word stopped the channel sending") + } +} + +// C6: a link's code is marked secret on every path to a channel, the desk's own role included, so the channel +// keeps no copy (the review found it dropped where the desk's role sends). +func TestALinksCodeIsSecretOnEveryPath(t *testing.T) { + c := start() + bench := NewBench(c.now) + bench.SetKinds([]KindInfo{ + {Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"reaches-away", "choice", "verified-sender", "exact-render"}}, + {Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"choice", "exact-render", "private"}}, + }) + bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + bench.Stand("desktop", Standing{Ready: true, At: c.now()}) + var mu sync.Mutex + put := map[string][]asks.Message{} + publish := func(subject string, body any, id string) error { + mu.Lock() + defer mu.Unlock() + if m, ok := body.(asks.Message); ok { + put[subject] = append(put[subject], m) + } + return nil + } + h := &Holder{Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish}, + Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish, + Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }} + h.init() + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + shown := put[asks.ChannelSubject(asks.Send, "desktop")] + if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") { + t.Fatalf("the code was not shown on the desk: %+v", put) + } + if !shown[0].Secret { + t.Error("the code went to the desk not marked secret: the channel would keep a copy") + } +} + +// L1: a warrant's message id is new each time: one anyone could foresee lets another publisher on the stream +// take it first, and the bus would drop the warrant as a duplicate. +func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + var ids []string + h.Publish = func(subject string, body any, id string) error { + if _, ok := body.(asks.Warrant); ok { + ids = append(ids, id) + } + return nil + } + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())); out != "chosen" { + t.Fatalf("%s %v", out, err) + } + _ = h.tell(*store.asks["mesh-delivery.d-1"].Warrant) + if len(ids) != 2 || ids[0] == ids[1] { + t.Fatalf("ids %v", ids) + } + for _, id := range ids { + if strings.Contains(id, "d-1") || strings.Contains(id, "mesh-delivery") { + t.Errorf("the id %q is made of what anyone knows of the ask", id) + } + } +} + +// M2: an ask's message says who asks, as the bus proves it, what each answer does, and what happens if nobody +// answers. +func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + body := tg.sends[0].Body + for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.", + "Silence for a week: nothing more is said of it for a week.", + "If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} { + if !strings.Contains(body, want) { + t.Errorf("the message does not say %q:\n%s", want, body) + } + } +} + +// M3: the router's own unlink binds the account it asks about: an approval from that account, after another was +// linked in its place, unlinks nothing. +func TestAnUnlinkUnlinksOnlyTheAccountItNamed(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + if _, err := h.Unlink("telegram"); err != nil { + t.Fatal(err) + } + unlink := ticketFor(tg.sends[len(tg.sends)-1], "Unlink") + delete(store.ids, identityKey("telegram", "42")) + store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Display: "Al", + Linked: c.now().Add(-2 * time.Hour), ApproveFrom: c.now().Add(-time.Hour)} + _, _ = h.Chosen("telegram", choiceOf(unlink, "42", c.now())) + if _, still := store.ids[identityKey("telegram", "77")]; !still { + t.Error("an approval to unlink 42 unlinked 77") + } + if got := store.asks; len(got) == 0 { + t.Fatal("no ask") + } + for _, rec := range store.asks { + if rec.Act != nil && rec.Act.Identity != "42" { + t.Errorf("the ask binds %q", rec.Act.Identity) + } + } +} + +// M3: a link is checked again as it is made: an approved relink does not link a second account of a kind. +func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}}) + code := codeOf(t, dt.sends[len(dt.sends)-1]) + _ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}) + asked := tg.sends[len(tg.sends)-1] + // Meanwhile root linked another account (allowed at the machine). + store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)} + _, _ = h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now())) + if _, linked := store.ids[identityKey("telegram", "666")]; linked { + t.Errorf("a second account of a kind was linked: %v", store.ids) + } +} + +// M3: an account unlinked less than a day ago is not linked again by a code: no code is shown for it. +func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + shown := len(dt.sends) + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if _, pending := store.links["telegram"]; pending { + t.Error("a link was begun for the account unlinked an hour ago") + } + for _, m := range dt.sends[shown:] { + if strings.Contains(m.Body, "send this code") { + t.Error("a code was shown for it") + } + } + if len(store.ids) != 0 { + t.Error("linked again") + } +} + +// H4: an account still in its first hour cannot approve yet, so whoever is at the desk may unlink it — asked +// there when another account asks to be linked — and past its hour the desk no longer can. +func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) { + h, tg, dt, c, store, w := newRouter(t) + link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") { + t.Fatalf("the sender was not told how: %q", reply.Body) + } + asked := dt.sends[len(dt.sends)-1] + if labelsOf(asked) != "Unlink it" || !strings.Contains(asked.Body, "number 666") { + t.Fatalf("the desk was not asked: %+v", asked) + } + ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) + ch.Sender.Authenticated = false + if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if len(store.ids) != 0 { + t.Errorf("not unlinked: %v", store.ids) + } + if len(w.of(SelfAsker)) != 0 { + t.Error("the router's own act was told to an asker") + } + // Past its hour, no question goes to the desk. + h2, tg2, dt2, c2, store2, _ := newRouter(t) + link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour)) + h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}}) + for _, m := range dt2.sends { + if labelsOf(m) == "Unlink it" { + t.Error("the desk was asked to unlink an account past its first hour") + } + } + if !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "own approval, or as root") { + t.Errorf("%q", tg2.sends[len(tg2.sends)-1].Body) + } +} + +// H4 (C4): while the desk cannot show a code now — its notifications paused — no link is begun and no code is +// made or queued; the sender is told why. +func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()}) + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if _, pending := store.links["telegram"]; pending { + t.Error("a link was begun") + } + if len(dt.sends) != 0 { + t.Errorf("something was put to the desk: %+v", dt.sends) + } + if reply := tg.sends[len(tg.sends)-1].Body; !strings.Contains(reply, "notifications are paused on the desk") { + t.Errorf("the sender was not told why: %q", reply) + } +} + +// L5: links are bounded per account, and kept in the router's state: a stranger's tries do not stop the +// operator's link, and a restart forgets no count. +func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true} + for i := 0; i < LinksAnHour; i++ { + h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve}) + } + shown := len(dt.sends) + h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") { + t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body) + } + // A router started again on the same state still counts the stranger's tries. + h2, tg2, dt2, c2, _, _ := newRouter(t) + h2.Asks = store + c2.pass(c.now().Sub(c2.now())) + stand(h2, c2) + before := len(dt2.sends) + h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve}) + if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") { + t.Error("a restart forgot the count") + } +} + +// L7: two answers to one ask arriving at once — on two routers sharing the state — make exactly one warrant. +func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) { + h1, tg, _, c, store, w1 := newRouter(t) + link(h1, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h1, "mesh-delivery", heldAsk(c, "d-1")) + settle(h1, c) + release, stop := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Stop") + h2, _, _, _, _, w2 := newRouter(t) + h2.Asks = store + h2.Now = h1.Now + if err := h2.loadAsks(); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + outs := make([]string, 2) + for i, pick := range []struct { + h *Holder + ticket string + }{{h1, release}, {h2, stop}} { + wg.Add(1) + go func(i int, h *Holder, ticket string) { + defer wg.Done() + outs[i], _ = h.Chosen("telegram", choiceOf(ticket, "42", c.now())) + }(i, pick.h, pick.ticket) + } + wg.Wait() + n := len(w1.of("mesh-delivery")) + len(w2.of("mesh-delivery")) + if n != 1 { + t.Fatalf("%d warrants for one ask (%v)", n, outs) + } + if (outs[0] == "chosen") == (outs[1] == "chosen") { + t.Errorf("not exactly one chosen: %v", outs) + } +} diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go index 12f1c09f..36446ec9 100644 --- a/modules/messenger/cmd/messenger/seatchannel.go +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -57,8 +57,14 @@ type Bench struct { kinds map[string]KindInfo standing map[string]Standing known bool + knownAt time.Time } +// KindsFresh is how long the controller's word on the kinds is believed for a proof: the router reads it every +// half minute, so an older word is one it could not read again. A stale word keeps the kinds for sending, and +// promises no verified sender (the review of 2026-10-09: a capability the controller withdrew must not stay). +const KindsFresh = 2 * time.Minute + // NewBench is a bench that knows no kind yet. func NewBench(now func() time.Time) *Bench { return &Bench{Now: now, kinds: map[string]KindInfo{}, standing: map[string]Standing{}} @@ -73,6 +79,7 @@ func (b *Bench) SetKinds(kinds []KindInfo) { b.kinds[k.Kind] = k } b.known = true + b.knownAt = b.Now() } // Stand takes what a kind said of itself. @@ -108,6 +115,14 @@ func (b *Bench) Promises(kind, capability string) bool { if !ok { return false } + if capability == "verified-sender" { + b.mu.Lock() + at := b.knownAt + b.mu.Unlock() + if age := b.Now().Sub(at); age > KindsFresh || age < 0 { + return false + } + } for _, c := range k.Capabilities { if c == capability { return true @@ -255,7 +270,10 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error { case len(m.Actions) > 0: verb = asks.Show } - out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply} + // Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it + // dropped here, where the desk's own role sends). + out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, + Secret: m.Secret} if c.Bench.To != nil { out.To = c.Bench.To(kind) } diff --git a/modules/messenger/cmd/messenger/selfact.go b/modules/messenger/cmd/messenger/selfact.go index bfe02111..20c890b9 100644 --- a/modules/messenger/cmd/messenger/selfact.go +++ b/modules/messenger/cmd/messenger/selfact.go @@ -16,6 +16,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "syscall" "time" @@ -29,11 +30,19 @@ const SelfAsker = "messenger" // RelinkWaits is how long after an unlink a new link waits for the replaced account's approval. const RelinkWaits = 24 * time.Hour -// SelfAct is what the router does on its own ask's warrant. +// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option +// binds that account, so a warrant for unlinking one account never unlinks another linked in its place +// (the review of 2026-10-09). type SelfAct struct { - Do string `json:"do"` // "unlink" or "link" - Kind string `json:"kind"` - Link *Identity `json:"link,omitempty"` // the account a link makes the operator's + Do string `json:"do"` // "unlink" or "link" + Kind string `json:"kind"` + Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's + Link *Identity `json:"link,omitempty"` // the account a link makes the operator's +} + +// act is what an option of the router's own ask binds: its named fields (asks.ActDigest). +func (a SelfAct) act() asks.Act { + return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity} } // Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it. @@ -45,7 +54,7 @@ type Unlinked struct { // askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask. func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error { // Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone. - binds, err := asks.ActDigest(act) + binds, err := asks.ActDigest(act.act()) if err != nil { return err } @@ -95,7 +104,7 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) { } o, err := w.For(SelfAsker, rec.Ask) if err == nil { - err = o.Performs(*rec.Act) + err = o.Performs(rec.Act.act()) } if err != nil { h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err) @@ -103,9 +112,23 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) { } switch rec.Act.Do { case "unlink": - h.unlinkNow(rec.Act.Kind, "approved by the account itself") + if o.Level == asks.Acknowledge { + // Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked + // this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself. + id := h.identity(rec.Act.Kind, rec.Act.Identity) + if id == nil || !h.Now().Before(id.ApproveFrom) { + h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed") + h.announce(Message{Title: "An unlink chosen at the desk was not done", + Body: "The account's first hour had passed, so only the account itself, or root at the router's " + + "machine, can unlink it now.", Urgent: true}) + return + } + h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked") + return + } + h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself") case "link": - if rec.Act.Link != nil { + if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity { h.linkNow(*rec.Act.Link, "approved by the account it replaces") } } @@ -126,7 +149,7 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) { "from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity), Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked", Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}} - if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind}); err != nil { + if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil { return nil, err } h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked", @@ -135,23 +158,27 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) { return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil } -// unlinkNow unlinks a kind's account: told first, by name, then every channel. -func (h *Holder) unlinkNow(kind, how string) { - ids := h.identitiesOf(kind) - if len(ids) == 0 || h.Asks == nil { +// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it +// is named, and nothing when another is linked now. +func (h *Holder) unlinkNow(kind, identity, how string) { + if h.Asks == nil { + return + } + id := h.identity(kind, identity) + if id == nil { + h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind) return } - id := ids[0] _ = h.showOn(kind, Message{Title: "This account is no longer linked as you", Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity}) if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil { h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err) return } - h.unlinked(kind, Unlinked{Identity: id, At: h.Now()}) + h.unlinked(kind, Unlinked{Identity: *id, At: h.Now()}) h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more", - Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval.", - id.Display, id.Identity, how), Urgent: true}) + Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval, "+ + "and that account itself can be linked again only after a day.", id.Display, id.Identity, how), Urgent: true}) h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how}) } @@ -160,6 +187,19 @@ func (h *Holder) linkNow(id Identity, how string) { if h.Asks == nil { return } + // One account per kind, checked again as it is linked: a link approved, or allowed by root, while + // another account was linked meanwhile is not made (the review of 2026-10-09). + for _, other := range h.identitiesOf(id.Kind) { + if other.Identity != id.Identity { + h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display) + h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made", + Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+ + "One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity), + Urgent: true}) + return + } + return // already linked + } now := h.Now() id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter) if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil { @@ -175,9 +215,12 @@ func (h *Holder) linkNow(id Identity, how string) { h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how}) } -// BreakGlass does what root asked at the router's machine: a file `unlink.` or `allow-link.` in the -// router's directory, owned by root. Called every minute. -func (h *Holder) BreakGlass() { +// breakGlass does what root asked at the router's machine: a file `unlink.` or `allow-link.` in +// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no +// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute +// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and +// every open question of the router's own ends, since root decided in its place. +func (h *Holder) breakGlass() { if h.StateDir == "" { return } @@ -199,20 +242,58 @@ func (h *Holder) BreakGlass() { Urgent: true}) continue } - h.work.Lock() + where := "at the router's machine" + if h.Node != "" { + where = "at " + h.Node + } + done := "" switch what { case "unlink": - h.unlinkNow(kind, "at the router's machine, as root") + if ids := h.identitiesOf(kind); len(ids) > 0 { + h.unlinkNow(kind, ids[0].Identity, where+", as root") + done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity) + } else { + done = "nothing was unlinked: no " + Display(kind) + " account is linked" + } case "allow-link": if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed { - h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed at the router's machine, as root") + h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root") + done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity) + } else { + done = "nothing was linked: no link with its code given waits on " + Display(kind) } } - h.work.Unlock() + ended := h.endSelfAsks("root decided " + where) + body := "Root " + where + " asked it: " + done + "." + if ended > 0 { + body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended) + } + h.Logf("[messenger] break-glass %s: %s", name, done) + h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true}) } } } +// endSelfAsks ends every open ask of the router's own: root decided in their place. +func (h *Holder) endSelfAsks(why string) int { + h.mu.Lock() + var keys []string + for k, e := range h.asks { + if e.rec.Asker == SelfAsker && e.rec.State == StateOpen { + keys = append(keys, k) + } + } + h.mu.Unlock() + sort.Strings(keys) + n := 0 + for _, k := range keys { + if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, ""); err == nil { + n++ + } + } + return n +} + // fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root. var fileOwner = func(info os.FileInfo) uint32 { if st, ok := info.Sys().(*syscall.Stat_t); ok { diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index 1fb6f3e0..4c5ebafe 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 +require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index 6fecb888..867b9f8a 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -1,2 +1,4 @@ git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/messenger/module.json b/modules/messenger/module.json index f6f2a13d..aa8bd5d8 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -82,7 +82,8 @@ "refused" ], "invokes": [ - "seat:mesh-controller.conditions" + "seat:mesh-controller.conditions", + "seat:mesh-controller.root-free" ], "state": [ "open",