Add the module mesh-mail-web, with its status verb (issue 1262)

This commit is contained in:
jochen
2026-10-19 22:07:36 +02:00
parent a3e9985c15
commit a9ad306693
11 changed files with 649 additions and 0 deletions
@@ -0,0 +1,77 @@
package main
// The trusted setting `operators` (novox/hq ADR 0440 §3, settlement 5 of design 59 milestone 1's prepare step):
// the Keycloak identities (the ID token's preferred_username) let in. Nobody by default; a setting that cannot
// be read, is empty, or holds something not its own shape lets nobody in either, and says why (issue 1262/AC4).
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
)
// Env is where the mesh puts what the module reads, as the manifest's build env names it.
type Env struct {
Operators string // the trusted setting operators
OIDC string // the oidc-client binding, rendered (issue 1262's oidc.json)
Listen string // the port the mesh gave endpoint `web` (MESH_MAIL_WEB_LISTEN)
Node string // this machine's own name (MESH_NODE), for the internal host mail.<node>.internal
}
func envFromOS() Env {
return Env{
Operators: os.Getenv("MESH_MAIL_WEB_OPERATORS"),
OIDC: os.Getenv("MESH_MAIL_WEB_OIDC"),
Listen: os.Getenv("MESH_MAIL_WEB_LISTEN"),
Node: os.Getenv("MESH_NODE"),
}
}
// OperatorsFile is the shape of the setting as the mesh writes it.
type OperatorsFile struct {
Operators []string `json:"operators"`
}
// ReadOperators reads the operators setting (issue 1262/AC4): a setting missing, empty, or holding an unknown
// field lets nobody in, and the reason is kept (never silently "everyone", never a panic).
func ReadOperators(path string) ([]string, error) {
if path == "" {
return nil, errors.New("the module was started without its operators setting (MESH_MAIL_WEB_OPERATORS)")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("the operators setting cannot be read: %w", err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var f OperatorsFile
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("the operators setting is not the module's: %w", err)
}
return f.Operators, nil
}
// Operators is the setting read fresh each time it is asked (never cached): it who may sign in, so a setting
// that cannot be read grants nobody rather than whoever it last allowed.
type Operators struct {
Path string
}
// Allows says whether identity (the ID token's preferred_username) is let in, and why not when it is refused.
func (o Operators) Allows(identity string) (bool, string) {
if identity == "" {
return false, "no identity was given"
}
names, err := ReadOperators(o.Path)
if err != nil {
return false, err.Error()
}
for _, n := range names {
if n == identity {
return true, ""
}
}
return false, fmt.Sprintf("%q is not in the setting operators", identity)
}
@@ -0,0 +1,56 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
// 1262/AC4: the operators file missing, empty, or holding an unknown field lets nobody in, and says why.
func TestOperatorsLetsNobodyInWhenTheSettingIsMissingEmptyOrUnknown(t *testing.T) {
dir := t.TempDir()
missing := Operators{Path: filepath.Join(dir, "does-not-exist.json")}
if ok, why := missing.Allows("alice"); ok || why == "" {
t.Fatalf("missing: allowed=%v why=%q", ok, why)
}
empty := filepath.Join(dir, "empty.json")
writeFile(t, empty, ``)
if ok, why := (Operators{Path: empty}).Allows("alice"); ok || why == "" {
t.Fatalf("empty: allowed=%v why=%q", ok, why)
}
unknown := filepath.Join(dir, "unknown.json")
writeFile(t, unknown, `{"operators": ["alice"], "surprise": true}`)
if ok, why := (Operators{Path: unknown}).Allows("alice"); ok || why == "" {
t.Fatalf("unknown field: allowed=%v why=%q", ok, why)
}
// A well-formed setting still lets nobody in who is not named.
good := filepath.Join(dir, "good.json")
writeFile(t, good, `{"operators": ["alice"]}`)
if ok, _ := (Operators{Path: good}).Allows("bob"); ok {
t.Fatalf("bob was let in by a setting naming only alice")
}
if ok, why := (Operators{Path: good}).Allows("alice"); !ok || why != "" {
t.Fatalf("alice: allowed=%v why=%q", ok, why)
}
}
// Also 1262/AC4: an empty operators list (the default) lets nobody in.
func TestOperatorsDefaultEmptyListLetsNobodyIn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "operators.json")
writeFile(t, path, `{"operators": []}`)
if ok, why := (Operators{Path: path}).Allows("anyone"); ok || why == "" {
t.Fatalf("allowed=%v why=%q", ok, why)
}
}
@@ -0,0 +1,31 @@
// mesh-mail-web: the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
// says on stderr; stdout is the MCP channel.
package main
import (
"fmt"
"io"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
func main() {
b := &Bundle{Env: envFromOS()}
if err := run(b, os.Stdin, os.Stdout); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
// (mirroring mesh-mail-notifier's own run).
func run(b *Bundle, in io.Reader, out io.Writer) error {
return stdio.ServeOn("", b.Tools(), in, out)
}
@@ -0,0 +1,176 @@
package main
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
//
// 1262/AC5: the repository's own way to run "the controller's manifest rules" is merge-check.sh, which shells
// out to the real controller binary ("$MESH_GATE" module check modules/*/module.json, merge-check.sh:127) — not
// something this package can call as a unit test, and this batch's brief refuses running merge-check.sh or any
// integration test. mesh-mail's and mesh-mail-notifier's own manifest_test.go files do not run the controller
// either: they assert the manifest's shape by hand, field by field, in Go. There is no unit-level way in this
// repository to run the controller's actual module-check rules, so TestTheManifestSaysWhatTheCodeDoes below is
// as far as the repo allows for AC5: it is the same hand-written shape assertion the sibling modules use, now
// covering every field AC1 names. The real module check still runs once, for real, through the lead's
// mesh-controller.try.
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type listen struct {
Name string `json:"name"`
Port int `json:"port"`
Protocol string `json:"protocol"`
From string `json:"from"`
}
type configEntry struct {
Type string `json:"type"`
Trusted bool `json:"trusted"`
Default []string `json:"default"`
}
type manifest struct {
Module string `json:"module"`
RunsAs string `json:"runs-as"`
Claims []seat `json:"claims"`
Invokes []string `json:"invokes"`
Reads []string `json:"reads"`
Requires []string `json:"requires"`
Contributes map[string]map[string]any `json:"contributes"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
Listens []listen `json:"listens"`
Config map[string]configEntry `json:"config"`
Tools []string `json:"tools"`
State []any `json:"state"`
OwnSecrets map[string]string `json:"own-secrets"`
SecretsOwner string `json:"secrets-owner"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []struct {
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
Env map[string]string `json:"env"`
} `json:"artifacts"`
} `json:"build"`
}
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
}
// 1262/AC1 (and, as far as the repo allows, AC5 — see the file's own comment above).
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
m := readManifest(t)
if m.Module != "mesh-mail-web" {
t.Fatalf("module %q", m.Module)
}
if m.RunsAs != "mesh-mail-web" {
t.Fatalf("runs-as %q, want its own account", m.RunsAs)
}
wantClaims := []seat{{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}}
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
}
wantInvokes := []string{
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
"seat:mail-store.send", "seat:mail-store.send-status",
}
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
t.Fatalf("invokes %v, want exactly the five mail-store verbs %v", m.Invokes, wantInvokes)
}
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
}
if !contains(m.Requires, "route") || !contains(m.Requires, "oidc-client") {
t.Fatalf("requires %v, want route and oidc-client", m.Requires)
}
if len(m.Listens) != 1 || m.Listens[0].Name != "web" || m.Listens[0].From != "mesh" || m.Listens[0].Protocol != "tcp" {
t.Fatalf("listens %+v, want exactly one, named web, from the mesh only", m.Listens)
}
op, ok := m.Config["operators"]
if !ok || op.Type != "array" || !op.Trusted || len(op.Default) != 0 {
t.Fatalf("config.operators %+v, want a trusted array defaulting to nobody", op)
}
// No database, no state bucket, no writable directory beyond the account's own state dir: only the
// account (a user resource, not writable), the state directory, and the two files rendered into it.
if len(m.State) != 0 {
t.Fatalf("state %v, want none", m.State)
}
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
if contains(m.Requires, dbish) {
t.Fatalf("requires %v, want no database or object store", m.Requires)
}
}
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
if len(m.Resources) != len(wantResourceIDs) {
t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs)
}
for _, r := range m.Resources {
id := r["id"].(string)
if !wantResourceIDs[id] {
t.Fatalf("an unexpected resource %q: no writable directory beyond the state dir is wanted", id)
}
if id != "account" && r["type"] == "directory" && id != "state" {
t.Fatalf("an extra writable directory %q", id)
}
}
// The manifest's own tools match the code's (mirroring mesh-mail's and mesh-mail-notifier's own check).
var names []string
for _, tool := range (&Bundle{}).Tools() {
names = append(names, tool.Name)
}
if !reflect.DeepEqual(m.Tools, names) {
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
}
a := m.Build.Artifacts
if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) {
t.Fatalf("build %+v", m.Build)
}
for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} {
found := false
for _, r := range m.Resources {
if r["id"] == v {
found = a[0].Env[k] == r["path"]
}
}
if !found {
t.Fatalf("the bundle reads %s at %s, which does not match resource %s's path", k, a[0].Env[k], v)
}
}
if a[0].Env["MESH_MAIL_WEB_LISTEN"] != "${port:web}" {
t.Fatalf("MESH_MAIL_WEB_LISTEN is %q, want the web endpoint's port", a[0].Env["MESH_MAIL_WEB_LISTEN"])
}
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
@@ -0,0 +1,106 @@
package main
// The module's seat verbs (novox/hq ADR 0440 §8): mail-webclient.status (whether it serves, and which accounts
// it is granted) and mail-webclient.open (issue 1283: the page's address). mail_web_health is its own health
// tool, read-only, naming nothing of a page or an account.
import (
"context"
"encoding/json"
"fmt"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tool names, as the manifest declares them.
const (
StatusTool = "mail-webclient.status"
HealthTool = "mail_web_health"
)
// AccountsAsk is how the Bundle reaches seat:mail-store.accounts: the bus's own stdio.Ask, unless a test gives
// its own fake (so a unit test needs no runtime).
type AccountsAsk func(ctx context.Context) (json.RawMessage, error)
func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
}
// Bundle is what the tools reach: the module's own settings, and the seat it calls.
type Bundle struct {
Env Env
Accounts AccountsAsk
Now func() time.Time
}
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
if b.Accounts != nil {
return b.Accounts(ctx)
}
return sdkAccountsAsk(ctx)
}
func (b *Bundle) now() time.Time {
if b.Now != nil {
return b.Now()
}
return time.Now()
}
// Tools are the module's tools.
func (b *Bundle) Tools() []stdio.Tool {
return []stdio.Tool{
{Name: StatusTool,
Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " +
"seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.statusTool},
{Name: HealthTool,
Description: "How mesh-mail-web is: healthy once its own settings can be read, unhealthy naming why " +
"otherwise. Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.healthTool},
}
}
// statusTool is issue 1262/AC2, AC3: mail-store's own accounts answer, read as the granted accounts; an ask
// mail-store does not answer, or refuses, answers "it does not serve" and why, never a crash.
func (b *Bundle) statusTool(map[string]any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
raw, err := b.accountsAsk(ctx)
if err != nil {
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store does not answer: %v", err)}, nil
}
var answer struct {
Accounts []struct {
Account string `json:"account"`
} `json:"accounts"`
}
if err := json.Unmarshal(raw, &answer); err != nil {
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store's answer could not be read: %v", err)}, nil
}
names := make([]string, 0, len(answer.Accounts))
for _, a := range answer.Accounts {
names = append(names, a.Account)
}
return map[string]any{"serves": true, "accounts": names}, nil
}
func (b *Bundle) healthTool(map[string]any) (any, error) {
if _, err := ReadOperators(b.Env.Operators); err != nil {
return map[string]any{"healthy": false, "why": err.Error()}, nil
}
return map[string]any{"healthy": true}, nil
}
// ---- schema helpers (mirroring mesh-mail's tools.go) -----------------------------------------------------------
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
}
@@ -0,0 +1,64 @@
package main
import (
"context"
"encoding/json"
"errors"
"testing"
)
// 1262/AC2: mail-store answers accounts a and b; status answers that it serves, and is granted a and b.
func TestStatusAnswersItServesAndTheGrantedAccounts(t *testing.T) {
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
return json.Marshal(map[string]any{"accounts": []map[string]any{
{"account": "a", "kind": "imap"}, {"account": "b", "kind": "imap"},
}})
}}
out, err := b.statusTool(nil)
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if m["serves"] != true {
t.Fatalf("serves: %v", m["serves"])
}
got, _ := m["accounts"].([]string)
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("accounts: %v", m["accounts"])
}
}
// 1262/AC3: mail-store does not answer (or refuses); status answers that it does not serve, and why, and does
// not panic.
func TestStatusAnswersItDoesNotServeWhenMailStoreRefuses(t *testing.T) {
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
return nil, errors.New("refused: mesh-mail-web is not granted any account")
}}
out, err := b.statusTool(nil)
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if m["serves"] != false {
t.Fatalf("serves: %v", m["serves"])
}
why, _ := m["why"].(string)
if why == "" {
t.Fatalf("no reason given: %v", m)
}
}
// Same AC3, the other half: mail-store answers something this module cannot read.
func TestStatusAnswersItDoesNotServeWhenMailStoresAnswerCannotBeRead(t *testing.T) {
b := &Bundle{Accounts: func(context.Context) (json.RawMessage, error) {
return json.RawMessage(`{"accounts": "not a list"}`), nil
}}
out, err := b.statusTool(nil)
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if m["serves"] != false || m["why"] == "" {
t.Fatalf("%v", m)
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"client-id": "${bound:oidc-client:as}",
"client-secret": "${secret:oidc-client}",
"issuer": "${bound:oidc-client:issuer}"
}
@@ -0,0 +1,3 @@
{
"operators": []
}
+5
View File
@@ -0,0 +1,5 @@
module mesh-mail-web
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c
+2
View File
@@ -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=
+124
View File
@@ -0,0 +1,124 @@
{
"module": "mesh-mail-web",
"version": "1",
"slug": "mail-web",
"runs-as": "mesh-mail-web",
"claims": [
{
"name": "mail-webclient",
"scope": "mesh",
"serves": ["open", "status"]
}
],
"invokes": [
"seat:mail-store.accounts",
"seat:mail-store.search",
"seat:mail-store.read",
"seat:mail-store.send",
"seat:mail-store.send-status"
],
"reads": ["mesh-mail.accounts"],
"requires": ["route", "oidc-client"],
"contributes": {
"route": {
"label": "mail",
"endpoint": "web"
},
"oidc-client": {
"label": "mail",
"endpoint": "web",
"callback": "/signed-in"
}
},
"binds": {
"route": "${dir:state}/route.json",
"oidc-client": "${dir:state}/oidc-bound.json"
},
"secrets": {
"oidc-client": "${dir:state}/oidc-bound.secret"
},
"listens": [
{
"name": "web",
"port": 8099,
"protocol": "tcp",
"from": "mesh",
"why": "the mail web client's pages, over http; only the route proxy reaches it, and the operator's own browser through the internal name (novox/hq ADR 0440 §2) — its public name, if any, is a route grant"
}
],
"config": {
"operators": {
"type": "array",
"description": "The Keycloak identities (the ID token's preferred_username) let to sign in; nobody by default (novox/hq ADR 0440 §3).",
"trusted": true,
"default": []
}
},
"tools": [
"mail-webclient.status",
"mail_web_health"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-mail-web",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-mail-web",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-mail-web"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-mail-web",
"place": ".",
"unknown": "program"
},
{
"id": "operators",
"trusted": true,
"type": "file",
"path": "${dir:state}/operators.json",
"mode": "0600",
"owner": "mesh-mail-web",
"merge": "json",
"template": "files/operators.json",
"takes-config": ["operators"]
},
{
"id": "oidc-config",
"type": "file",
"path": "${dir:state}/oidc.json",
"mode": "0600",
"owner": "mesh-mail-web",
"template": "files/oidc.json"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-mail-web",
"binary": "mesh-mail-web",
"loads": ["mesh-mail-web"],
"env": {
"MESH_MAIL_WEB_OPERATORS": "${dir:state}/operators.json",
"MESH_MAIL_WEB_OIDC": "${dir:state}/oidc.json",
"MESH_MAIL_WEB_LISTEN": "${port:web}"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}