From d17b7e832d118189667e58089443a4f7559e337f Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 23:50:02 +0200 Subject: [PATCH] Declare the seat mail-reading and the module mesh-mail-reader's skeleton (issue 1426) --- modules/mesh-mail-reader/README.md | 48 ++++ .../cmd/mesh-mail-reader/callers.go | 84 ++++++ .../cmd/mesh-mail-reader/callers_test.go | 52 ++++ .../cmd/mesh-mail-reader/main.go | 37 +++ .../cmd/mesh-mail-reader/manifest_test.go | 82 ++++++ .../cmd/mesh-mail-reader/tools.go | 239 ++++++++++++++++++ .../cmd/mesh-mail-reader/tools_test.go | 100 ++++++++ modules/mesh-mail-reader/files/callers.json | 3 + modules/mesh-mail-reader/go.mod | 5 + modules/mesh-mail-reader/go.sum | 2 + modules/mesh-mail-reader/module.json | 88 +++++++ .../mesh-mail/cmd/mesh-mail/manifest_test.go | 8 +- modules/mesh-mail/module.json | 5 + 13 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 modules/mesh-mail-reader/README.md create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/callers_test.go create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go create mode 100644 modules/mesh-mail-reader/files/callers.json create mode 100644 modules/mesh-mail-reader/go.mod create mode 100644 modules/mesh-mail-reader/go.sum create mode 100644 modules/mesh-mail-reader/module.json diff --git a/modules/mesh-mail-reader/README.md b/modules/mesh-mail-reader/README.md new file mode 100644 index 00000000..c6e06e2d --- /dev/null +++ b/modules/mesh-mail-reader/README.md @@ -0,0 +1,48 @@ +# mesh-mail-reader — the default holder of mail-reading + +Design 59, milestone 2 (mail reading); built from mesh-mail's own source, like mesh-mail-web and +mesh-mail-notifier (novox/hq ADR 0459). One instance, held once for the mesh, on the machine mesh-mail runs +on, under its own account (`runs-as`) — it hears mesh-mail's restricted stream `MAIL`, which the operator +account never does. It reads mail; it moves, flags, deletes and sends nothing (ADR 0459 §11). + +## What it does, and what is still to land + +- **Claims `mail-reading`** (held once for the mesh), declared in mesh-mail's own manifest beside its three + mail seats, serving six verbs: `entries`, `groups`, `correct`, `lessons`, `undo`, `status` (ADR 0459 §2). + Each answers only a caller the trusted setting `callers` names (default `["mesh-mail-web"]`); any other + caller is refused, naming why. +- **Hears mesh-mail's added events** and judges a batch of up to 10 at a time against a question put to + `claude_code_judge`, keeping an entry per mail — never its body — in its own database (issue 1518, 1427; + not yet built). +- **Keeps a health of its own**, `mail_reader_health`: unhealthy once a judgement is refused, times out or + answers out of shape; healthy again once one succeeds (issue 1519; not yet built). +- **Tells the operator** at most once a batch when a mail matters, and corrects itself at once, keeping a + reading lesson, when the operator says it judged wrong (issue 1428, 1429, 1522; not yet built). + +Until each of those issues lands, its own verb answers that it is not built yet, once the caller check above +has already passed or refused. + +## Its database + +Its own `postgres-database` (issue 1517; not yet declared in the manifest until then): mail entries, mail +groups, reading lessons, the mails heard but not yet read, and its own health state. No column holds a mail +body or text taken from one. + +## Tools + +| tool | does | +|---|---| +| `mail-reading.entries` | every mail entry: headers, mail group, summary, mark and why, due date. Read-only | +| `mail-reading.groups` | every mail group a batch has made, largest first. Read-only | +| `mail-reading.correct` | correct one entry's mark, why or due date; kept at once as a reading lesson | +| `mail-reading.lessons` | the reading lessons kept, at most 40. Read-only | +| `mail-reading.undo` | undo one reading lesson | +| `mail-reading.status` | lessons kept, undone, and per 100 mails read this week. Read-only | +| `mail_reader_health` | how mesh-mail-reader is: unhealthy once a judgement is refused, times out or answers out of shape, naming which and since when; healthy once one succeeds. Read-only | + +## Where it runs + +**Its own account** (`runs-as`), because it hears mesh-mail's restricted stream `MAIL` (novox/hq ADR 0376 +§2). Held once for the mesh, on the machine mesh-mail runs on; it is granted reading only, after issue 1227's +split of mesh-mail's grants is delivered (ADR 0472: that split is won't-fix — mesh-mail's `grants` stays one +list per account, and mesh-mail-reader is put on it after delivery, never asking to send). diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go new file mode 100644 index 00000000..6d666e91 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go @@ -0,0 +1,84 @@ +package main + +// Callers is the trusted setting `callers` (lead-m2@g14's decision at this milestone's prepare step, said to +// the operator; novox/hq ADR 0459 §2: "each answers only a caller the operator grants it"): who may call one +// of mail-reading's six verbs. An entry is a bare module name (granted wherever it runs, on any machine), +// exactly ., or exactly person. — the same shapes mesh-mail's own trusted setting +// `grants` matches (modules/mesh-mail/cmd/mesh-mail/config.go, Grants.Allows), copied here in miniature (one +// flat list, not one per account) so mesh-mail-reader never imports mesh-mail. Nobody beyond the list is +// granted, a wildcard in the list is no entry, and an unproven caller never matches. The setting's default is +// ["mesh-mail-web"]. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" +) + +// Unproven is the caller the runtime proved nobody to be (stdio.Unproven; copied so this file reads alone). +const Unproven = "unproven" + +// Callers is the setting's shape once read: a flat list of entries. +type Callers []string + +// callersFile is the shape of the setting as the mesh writes it. +type callersFile struct { + Callers Callers `json:"callers"` +} + +// ReadCallers reads the callers setting. A setting that cannot be read allows nobody, and says why: a caller +// refused for a reason it can read is better than one let through because the setting was unreadable. +func ReadCallers(path string) (Callers, error) { + if path == "" { + return nil, errors.New("the module was started without its callers setting (MESH_MAIL_READER_CALLERS)") + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("the callers setting cannot be read: %w", err) + } + return ParseCallers(raw) +} + +// ParseCallers reads the callers setting's content. +func ParseCallers(raw []byte) (Callers, error) { + var f callersFile + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("the callers setting is not the module's: %v", err) + } + return f.Callers, nil +} + +// Allows says whether the caller may call a mail-reading verb: a bare module name (on any machine, as long as +// the caller is not person.), exactly ., or exactly person.. Nobody is granted by +// default, an unproven caller never, and a wildcard entry is no entry (mesh-mail's own Grants.Allows mirrored). +func (c Callers) Allows(caller string) bool { + if caller == "" || caller == Unproven { + return false + } + for _, entry := range c { + if entry == "" || strings.ContainsAny(entry, "*>") { + continue + } + if entry == caller { + return true + } + if !strings.Contains(entry, ".") { + if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") { + return true + } + } + } + return false +} + +// refuseCaller says why the caller is refused a mail-reading verb: the setting unread, or simply not named in it. +func refuseCaller(readErr error, caller string) error { + if readErr != nil { + return fmt.Errorf("refused: the setting callers cannot be read, so it allows nobody: %w", readErr) + } + return fmt.Errorf("refused: %s is not named in the setting callers; the operator grants it there", caller) +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers_test.go new file mode 100644 index 00000000..19349c7f --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers_test.go @@ -0,0 +1,52 @@ +package main + +// 1426/AC4: the trusted setting callers, per shape, and an unproven caller. + +import "testing" + +func TestCallersAllowsEachShape(t *testing.T) { + c := Callers{"mesh-mail-web", "novox.mesh-knowledge", "person.jochen"} + cases := []struct { + caller string + want bool + }{ + {"mesh-mail-web", true}, // a bare module name + {"shanks.mesh-mail-web", true}, // ... on any machine + {"novox.mesh-knowledge", true}, // exactly . + {"shanks.mesh-knowledge", false}, // ... but not on another machine + {"person.jochen", true}, // exactly person. + {"person.someoneelse", false}, // ... but not another person + {"mesh-mail-notifier", false}, // not named at all + {"", false}, + {Unproven, false}, // 1426/AC4: unproven never + } + for _, c2 := range cases { + if got := c.Allows(c2.caller); got != c2.want { + t.Errorf("Allows(%q) = %v, want %v", c2.caller, got, c2.want) + } + } +} + +// 1426/AC4: a wildcard entry is no entry (mirrors mesh-mail's own Grants.Allows). +func TestCallersWildcardEntryIsNoEntry(t *testing.T) { + c := Callers{"*", "mesh-mail.>"} + if c.Allows("anything") { + t.Fatal("a wildcard entry granted a caller") + } +} + +func TestParseCallersDefault(t *testing.T) { + c, err := ParseCallers([]byte(`{"callers": ["mesh-mail-web"]}`)) + if err != nil { + t.Fatal(err) + } + if len(c) != 1 || c[0] != "mesh-mail-web" { + t.Fatalf("callers = %v", c) + } +} + +func TestReadCallersEmptyPath(t *testing.T) { + if _, err := ReadCallers(""); err == nil { + t.Fatal("no error reading callers with no path") + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go new file mode 100644 index 00000000..3796f6f6 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go @@ -0,0 +1,37 @@ +// mesh-mail-reader: the default claimant of mail-reading (novox/hq ADR 0459 §1), built from mesh-mail's own +// source, like mesh-mail-web and mesh-mail-notifier. One instance, held once for the mesh, on the machine +// mesh-mail runs on. It hears mesh-mail's added events under its own runs-as account (ADR 0459 §3, issue +// 1518 — not yet built), judges a batch of them at a time (1427, 1520 — not yet built), and serves the six +// mail-reading verbs (entries, groups, correct, lessons, undo, status) and its own health tool, +// mail_reader_health (1519 — not yet built). It reads mail; it moves, flags, deletes and sends nothing +// (novox/hq ADR 0459 §11). +// +// mesh-mail-reader the bundle the node's runtime launches: the six mail-reading verbs plus +// mail_reader_health, served over stdio (tools.go) +// +// stdout is the bundle's MCP channel; what this module says, it says on stderr. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Module is this module's name. +const Module = "mesh-mail-reader" + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +func envFromOS() Env { + return Env{Callers: os.Getenv("MESH_MAIL_READER_CALLERS")} +} + +func main() { + b := &Bundle{Env: envFromOS()} + if err := stdio.ServeOn("", b.Tools(), os.Stdin, os.Stdout); err != nil { + logf("[mesh-mail-reader] %v", err) + os.Exit(1) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go new file mode 100644 index 00000000..bf85282d --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go @@ -0,0 +1,82 @@ +package main + +// The manifest says what the code does (novox/hq ADR 0459 §1, §2; issue 1426): the seat it claims and the +// verbs it serves, and that it invokes mail-store's read-only verbs alone — never one that changes the +// mailbox (ADR 0472: mesh-mail-reader is granted reading only, and its manifest invokes no send verb). + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +type seat struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` +} + +type manifest struct { + Module string `json:"module"` + RunsAs string `json:"runs-as"` + Claims []seat `json:"claims"` + Invokes []string `json:"invokes"` + Tools []string `json:"tools"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +// 1426/AC2: runs-as mesh-mail-reader, claims mail-reading (scope mesh) with the six verbs, and invokes +// seat:mail-store.read and seat:mail-store.accounts but no send, send-status or any verb that changes the +// mailbox. +func TestTheManifestClaimsMailReadingAndInvokesReadOnly(t *testing.T) { + m := readManifest(t) + if m.Module != Module { + t.Fatalf("module %q, want %q", m.Module, Module) + } + if m.RunsAs != Module { + t.Fatalf("runs-as %q, want %q", m.RunsAs, Module) + } + wantClaims := []seat{ + {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}}, + } + if !reflect.DeepEqual(m.Claims, wantClaims) { + t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) + } + wantInvokes := []string{"seat:mail-store.read", "seat:mail-store.accounts"} + if !reflect.DeepEqual(m.Invokes, wantInvokes) { + t.Fatalf("invokes %v, want exactly %v (no send, send-status, or any verb that changes the mailbox)", m.Invokes, wantInvokes) + } + for _, forbidden := range []string{"seat:mail-store.send", "seat:mail-store.send-status"} { + for _, inv := range m.Invokes { + if inv == forbidden { + t.Fatalf("invokes names %s, which changes the mailbox (ADR 0472)", forbidden) + } + } + } +} + +// 1426/AC3 (the manifest's own half): the manifest's tools match the bundle's Tools(), in the same order. +func TestTheManifestListsTheSameToolsAsTheBundle(t *testing.T) { + m := readManifest(t) + var names []string + for _, tool := range (&Bundle{}).Tools() { + names = append(names, tool.Name) + } + if !reflect.DeepEqual(m.Tools, names) { + t.Fatalf("manifest tools %v, the code serves %v", m.Tools, names) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go new file mode 100644 index 00000000..fd431899 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -0,0 +1,239 @@ +package main + +// The module's tools (novox/hq ADR 0459 §2, issue 1426): the six mail-reading seat verbs — entries, groups, +// correct, lessons, undo, status — and the module's own health tool, mail_reader_health (1519). Who calls is +// the runtime's word (stdio.CallerOf), never an argument; a mail-reading verb answers only a caller the +// trusted setting `callers` names (callers.go), and refuses any other, naming why. mail_reader_health is +// asked by the node-engine itself and carries no caller check. +// +// The six verbs' own behaviour — reading and writing mail entries, mail groups and reading lessons — is +// 1521 (entries, groups, status) and 1428 (correct, lessons, undo); mail_reader_health's own answer is 1519. +// Until each lands, its tool answers that it is not built yet, after the caller check has already passed or +// refused, so a caller never learns whether a verb is built before learning whether it is granted. + +import ( + "errors" + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat tool names (novox/hq ADR 0459 §2): mesh-mail-reader claims the mail-reading seat and serves its six +// verbs through these tool names, exactly as mesh-mail's own mail-store seat tools are named "mail-store.". +const ( + EntriesTool = "mail-reading.entries" + GroupsTool = "mail-reading.groups" + CorrectTool = "mail-reading.correct" + LessonsTool = "mail-reading.lessons" + UndoTool = "mail-reading.undo" + StatusTool = "mail-reading.status" +) + +// HealthTool is the module's own health tool (1519): the manifest's runtime-health, and the seventh of its +// seven tools. Defined here, not in a health.go of its own yet, so Tools() below compiles before 1519 lands. +const HealthTool = "mail_reader_health" + +// Bundle is what the tools reach: the trusted setting `callers`, and the runtime's own caller. Caller is nil +// outside a test (CallerOf is read from the runtime). The index (store.go) is 1517; the health tool reaches it +// from 1519 on. +type Bundle struct { + Env Env + Caller func(args map[string]any) string + + // callersOverride, when set, is used instead of reading Env.Callers — the tests' own seam. + callersOverride func() (Callers, error) +} + +// Env is where the bundle reads its settings from. +type Env struct { + Callers string // the path MESH_MAIL_READER_CALLERS names. +} + +func (b *Bundle) caller(args map[string]any) string { + if b.Caller != nil { + return b.Caller(args) + } + return stdio.CallerOf(args) +} + +func (b *Bundle) callers() (Callers, error) { + if b.callersOverride != nil { + return b.callersOverride() + } + return ReadCallers(b.Env.Callers) +} + +// checkCaller refuses args' caller unless the trusted setting `callers` names it (1426/AC4). It is the first +// thing every mail-reading verb does: a caller learns it is refused before it learns anything about the verb. +func (b *Bundle) checkCaller(args map[string]any) (string, error) { + caller := b.caller(args) + callers, err := b.callers() + if err != nil || !callers.Allows(caller) { + return caller, refuseCaller(err, caller) + } + return caller, nil +} + +// notBuiltYet is what a mail-reading verb answers once its caller is granted, until its own issue lands +// (1426/AC3's note: this is not acceptable at the end of the milestone, by which every verb answers; it is +// acceptable now, while 1521 and 1428 are still to come). +func notBuiltYet(tool, issue string) error { + return fmt.Errorf("%s is not built yet (issue %s)", tool, issue) +} + +// schema, text, integer and boolean mirror mesh-mail's own tools.go helpers (modules/mesh-mail/cmd/mesh-mail, +// copied in miniature so mesh-mail-reader never imports mesh-mail's tools package for its schemas alone). +func schema(required []string, props map[string]any) map[string]any { + s := map[string]any{"type": "object", "properties": props, "additionalProperties": false} + if len(required) > 0 { + s["required"] = required + } + return s +} + +func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} } +func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} } + +// only refuses an argument a tool does not take. +func only(args map[string]any, takes ...string) error { + for k := range args { + found := false + for _, t := range takes { + if t == k { + found = true + break + } + } + if !found { + return errors.New("the argument " + k + " is not taken") + } + } + return nil +} + +// Tools are the module's tools (1426/AC3): the six mail-reading verbs and mail_reader_health, each with its +// own argument schema. +func (b *Bundle) Tools() []stdio.Tool { + return []stdio.Tool{ + {Name: EntriesTool, + Description: "Every mail entry (novox/hq ADR 0459 §4): the mail's own headers, its mail group, a " + + "one-line summary, its mark and why, its due date — never a mail body. Answered only to a caller " + + "the setting callers names. Read-only.", + Input: schema(nil, map[string]any{ + "limit": integer("how many to answer, newest first; a default bounds it when left out"), + }), + Run: b.entriesTool}, + {Name: GroupsTool, + Description: "Every mail group a batch has made (novox/hq ADR 0459 §4, §5), largest first. " + + "Answered only to a caller the setting callers names. Read-only.", + Input: schema(nil, map[string]any{ + "limit": integer("how many to answer; a default bounds it when left out"), + }), + Run: b.groupsTool}, + {Name: CorrectTool, + Description: "Correct one mail entry's mark, why or due date (novox/hq ADR 0459 §8): kept at once as " + + "a reading lesson (its headers, the change, when), no operator Approve needed. Answered only to a " + + "caller the setting callers names.", + Input: schema([]string{"id"}, map[string]any{ + "id": integer("the mail entry's id, as entries answered it"), + "mark": text("the corrected mark: matters, scam warning or neither"), + "why": text("the corrected reason, one line"), + "due-date": text("the corrected due date (YYYY-MM-DD), or empty to clear it"), + }), + Run: b.correctTool}, + {Name: LessonsTool, + Description: "The reading lessons kept (novox/hq ADR 0459 §9): at most 40, the sender or group's own " + + "first, then newest; a lesson outranks a mail it would otherwise contradict. Answered only to a " + + "caller the setting callers names. Read-only.", + Input: schema(nil, map[string]any{ + "limit": integer("how many to answer; a default bounds it, at most 40, when left out"), + }), + Run: b.lessonsTool}, + {Name: UndoTool, + Description: "Undo one reading lesson (novox/hq ADR 0459 §9, issue 1523): it no longer outranks a " + + "mail. Answered only to a caller the setting callers names.", + Input: schema([]string{"id"}, map[string]any{ + "id": integer("the reading lesson's id, as lessons answered it"), + }), + Run: b.undoTool}, + {Name: StatusTool, + Description: "How mail reading stands for the page (novox/hq ADR 0459 §10): lessons kept, lessons " + + "undone, and lessons per 100 mails read this week. Answered only to a caller the setting callers " + + "names. Read-only.", + Input: schema(nil, map[string]any{}), + Run: b.statusTool}, + {Name: HealthTool, + Description: "How mesh-mail-reader is (novox/hq ADR 0459 §12): unhealthy once a batch judgement is " + + "refused, times out or answers out of shape, naming which and since when; healthy once one " + + "succeeds. Asked by the node-engine; carries no caller check. Read-only.", + Input: schema(nil, map[string]any{}), + Run: b.healthTool}, + } +} + +func (b *Bundle) entriesTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "limit"); err != nil { + return nil, err + } + return nil, notBuiltYet(EntriesTool, "1521") +} + +func (b *Bundle) groupsTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "limit"); err != nil { + return nil, err + } + return nil, notBuiltYet(GroupsTool, "1521") +} + +func (b *Bundle) correctTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "id", "mark", "why", "due-date"); err != nil { + return nil, err + } + return nil, notBuiltYet(CorrectTool, "1428") +} + +func (b *Bundle) lessonsTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "limit"); err != nil { + return nil, err + } + return nil, notBuiltYet(LessonsTool, "1428") +} + +func (b *Bundle) undoTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args, "id"); err != nil { + return nil, err + } + return nil, notBuiltYet(UndoTool, "1428") +} + +func (b *Bundle) statusTool(args map[string]any) (any, error) { + if _, err := b.checkCaller(args); err != nil { + return nil, err + } + if err := only(args); err != nil { + return nil, err + } + return nil, notBuiltYet(StatusTool, "1521") +} + +func (b *Bundle) healthTool(args map[string]any) (any, error) { + if err := only(args); err != nil { + return nil, err + } + return nil, notBuiltYet(HealthTool, "1519") +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go new file mode 100644 index 00000000..9fc3c808 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "strings" + "testing" +) + +// 1426/AC3: the bundle's Tools() lists the six mail-reading verbs and mail_reader_health, each with its own +// argument schema. +func TestToolsListsTheSixVerbsAndHealth(t *testing.T) { + b := &Bundle{} + var names []string + for _, tool := range b.Tools() { + names = append(names, tool.Name) + if tool.Input == nil { + t.Errorf("%s has no argument schema", tool.Name) + } + if tool.Description == "" { + t.Errorf("%s has no description", tool.Name) + } + } + want := []string{ + "mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", + "mail-reading.undo", "mail-reading.status", "mail_reader_health", + } + if len(names) != len(want) { + t.Fatalf("Tools() = %v, want %v", names, want) + } + for i, n := range want { + if names[i] != n { + t.Fatalf("Tools()[%d] = %q, want %q", i, names[i], n) + } + } +} + +// 1426/AC4: a mail-reading verb answers a caller the setting callers names, and refuses any other, naming why. +func TestAMailReadingVerbChecksItsCaller(t *testing.T) { + granted := func(map[string]any) string { return "mesh-mail-web" } + refused := func(map[string]any) string { return "person.someoneelse" } + unproven := func(map[string]any) string { return Unproven } + + for _, tool := range []struct { + name string + run func(*Bundle) (any, error) + }{ + {EntriesTool, func(b *Bundle) (any, error) { return b.entriesTool(map[string]any{}) }}, + {GroupsTool, func(b *Bundle) (any, error) { return b.groupsTool(map[string]any{}) }}, + {CorrectTool, func(b *Bundle) (any, error) { return b.correctTool(map[string]any{"id": float64(1)}) }}, + {LessonsTool, func(b *Bundle) (any, error) { return b.lessonsTool(map[string]any{}) }}, + {UndoTool, func(b *Bundle) (any, error) { return b.undoTool(map[string]any{"id": float64(1)}) }}, + {StatusTool, func(b *Bundle) (any, error) { return b.statusTool(map[string]any{}) }}, + } { + callers := func() (Callers, error) { return Callers{"mesh-mail-web"}, nil } + + b := &Bundle{Caller: granted, callersOverride: callers} + _, err := tool.run(b) + if err == nil || !strings.Contains(err.Error(), "not built yet") { + t.Errorf("%s granted: %v, want it to pass the caller check and answer not built yet", tool.name, err) + } + + b = &Bundle{Caller: refused, callersOverride: callers} + _, err = tool.run(b) + if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "person.someoneelse") { + t.Errorf("%s refused caller: %v, want a refusal naming person.someoneelse", tool.name, err) + } + + b = &Bundle{Caller: unproven, callersOverride: callers} + _, err = tool.run(b) + if err == nil || !strings.Contains(err.Error(), "refused") { + t.Errorf("%s unproven caller: %v, want a refusal", tool.name, err) + } + } +} + +// 1426/AC4: a setting that cannot be read allows nobody, and says why. +func TestAMailReadingVerbRefusesWhenCallersCannotBeRead(t *testing.T) { + b := &Bundle{ + Caller: func(map[string]any) string { return "mesh-mail-web" }, + callersOverride: func() (Callers, error) { return nil, errFake }, + } + _, err := b.entriesTool(map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "cannot be read") { + t.Fatalf("entriesTool with an unreadable setting: %v", err) + } +} + +// mail_reader_health carries no caller check (1426: "mail_reader_health is asked by the node-engine itself"). +func TestHealthToolHasNoCallerCheck(t *testing.T) { + b := &Bundle{callersOverride: func() (Callers, error) { return nil, errFake }} + _, err := b.healthTool(map[string]any{}) + if err == nil || strings.Contains(err.Error(), "refused") { + t.Fatalf("mail_reader_health: %v, want it to skip the caller check entirely", err) + } +} + +type fakeErr struct{} + +func (fakeErr) Error() string { return "the setting cannot be read (fake)" } + +var errFake = fakeErr{} diff --git a/modules/mesh-mail-reader/files/callers.json b/modules/mesh-mail-reader/files/callers.json new file mode 100644 index 00000000..84dd8003 --- /dev/null +++ b/modules/mesh-mail-reader/files/callers.json @@ -0,0 +1,3 @@ +{ + "callers": ["mesh-mail-web"] +} diff --git a/modules/mesh-mail-reader/go.mod b/modules/mesh-mail-reader/go.mod new file mode 100644 index 00000000..e9fc7dd3 --- /dev/null +++ b/modules/mesh-mail-reader/go.mod @@ -0,0 +1,5 @@ +module mesh-mail-reader + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c diff --git a/modules/mesh-mail-reader/go.sum b/modules/mesh-mail-reader/go.sum new file mode 100644 index 00000000..25aaa963 --- /dev/null +++ b/modules/mesh-mail-reader/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c h1:/f6f2ugss172hDJSL3EFZqb9PdfO5DKzYVfPiX2LdAM= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/mesh-mail-reader/module.json b/modules/mesh-mail-reader/module.json new file mode 100644 index 00000000..82ddc7f4 --- /dev/null +++ b/modules/mesh-mail-reader/module.json @@ -0,0 +1,88 @@ +{ + "module": "mesh-mail-reader", + "version": "1", + "runs-as": "mesh-mail-reader", + "claims": [ + { + "name": "mail-reading", + "scope": "mesh", + "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + } + ], + "invokes": [ + "seat:mail-store.read", + "seat:mail-store.accounts" + ], + "config": { + "callers": { + "type": "list", + "description": "Who may call one of mail-reading's six verbs (entries, groups, correct, lessons, undo, status): a module's name (on every machine), ., or person. (novox/hq ADR 0459 §2). mesh-mail-web alone by default.", + "trusted": true, + "default": ["mesh-mail-web"] + } + }, + "tools": [ + "mail-reading.entries", + "mail-reading.groups", + "mail-reading.correct", + "mail-reading.lessons", + "mail-reading.undo", + "mail-reading.status", + "mail_reader_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-reader", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-reader", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-reader" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-reader", + "place": ".", + "unknown": "program" + }, + { + "id": "callers", + "trusted": true, + "type": "file", + "path": "${dir:state}/callers.json", + "mode": "0600", + "owner": "mesh-mail-reader", + "merge": "json", + "template": "files/callers.json", + "takes-config": ["callers"] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-reader", + "binary": "mesh-mail-reader", + "loads": [ + "mesh-mail-reader" + ], + "env": { + "MESH_MAIL_READER_CALLERS": "${dir:state}/callers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index 2c791c9a..9e8b53cd 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -140,13 +140,15 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if len(m.Consumes)+len(m.Invokes) != 0 { t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes) } - // It declares exactly the three mail seats (ADR 0378 §1 as amended by ADR 0379 §4) — mail-store and - // mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier - // there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3). + // It declares the three mail seats (ADR 0378 §1 as amended by ADR 0379 §4) — mail-store and mail-webclient + // each held once for the mesh, mail-notifier held once per machine and needing node-notifier there — plus + // mail-reading (novox/hq ADR 0459 §1, issue 1426), held once for the mesh and claimed by mesh-mail-reader, + // never by mesh-mail itself; and claims only mail-store, serving the same five verbs as its own tools (§2, §3). wantSeats := []seat{ {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}}, {Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}, {Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}}, + {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}}, } if !reflect.DeepEqual(m.Seats, wantSeats) { t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats) diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index 1eba0cf3..735fbc54 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -18,6 +18,11 @@ "scope": "node", "needs": ["node-notifier"], "serves": ["status"] + }, + { + "name": "mail-reading", + "scope": "mesh", + "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] } ], "claims": [