Declare the seat mail-reading and the module mesh-mail-reader's skeleton (issue 1426)
This commit is contained in:
@@ -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).
|
||||
@@ -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 <machine>.<module>, or exactly person.<name> — 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.<name>), exactly <machine>.<module>, or exactly person.<name>. 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)
|
||||
}
|
||||
@@ -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 <machine>.<module>
|
||||
{"shanks.mesh-knowledge", false}, // ... but not on another machine
|
||||
{"person.jochen", true}, // exactly person.<name>
|
||||
{"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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.<verb>".
|
||||
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")
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"callers": ["mesh-mail-web"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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), <machine>.<module>, or person.<name> (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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
"scope": "node",
|
||||
"needs": ["node-notifier"],
|
||||
"serves": ["status"]
|
||||
},
|
||||
{
|
||||
"name": "mail-reading",
|
||||
"scope": "mesh",
|
||||
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
Reference in New Issue
Block a user