From a9ad306693b404e5f472e4b410f37ce2598fa8d6 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 22:07:36 +0200 Subject: [PATCH] Add the module mesh-mail-web, with its status verb (issue 1262) --- .../mesh-mail-web/cmd/mesh-mail-web/config.go | 77 ++++++++ .../cmd/mesh-mail-web/config_test.go | 56 ++++++ .../mesh-mail-web/cmd/mesh-mail-web/main.go | 31 +++ .../cmd/mesh-mail-web/manifest_test.go | 176 ++++++++++++++++++ .../mesh-mail-web/cmd/mesh-mail-web/tools.go | 106 +++++++++++ .../cmd/mesh-mail-web/tools_test.go | 64 +++++++ modules/mesh-mail-web/files/oidc.json | 5 + modules/mesh-mail-web/files/operators.json | 3 + modules/mesh-mail-web/go.mod | 5 + modules/mesh-mail-web/go.sum | 2 + modules/mesh-mail-web/module.json | 124 ++++++++++++ 11 files changed, 649 insertions(+) create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/config.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/config_test.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/main.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/tools.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/tools_test.go create mode 100644 modules/mesh-mail-web/files/oidc.json create mode 100644 modules/mesh-mail-web/files/operators.json create mode 100644 modules/mesh-mail-web/go.mod create mode 100644 modules/mesh-mail-web/go.sum create mode 100644 modules/mesh-mail-web/module.json diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/config.go b/modules/mesh-mail-web/cmd/mesh-mail-web/config.go new file mode 100644 index 00000000..8d419e81 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/config.go @@ -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..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) +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/config_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/config_test.go new file mode 100644 index 00000000..415a7df2 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/config_test.go @@ -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) + } +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go new file mode 100644 index 00000000..328a6c6b --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go @@ -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) +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go new file mode 100644 index 00000000..6f670ec3 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go @@ -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 +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go new file mode 100644 index 00000000..53638f44 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go @@ -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 +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/tools_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/tools_test.go new file mode 100644 index 00000000..d4986149 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/tools_test.go @@ -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) + } +} diff --git a/modules/mesh-mail-web/files/oidc.json b/modules/mesh-mail-web/files/oidc.json new file mode 100644 index 00000000..f8646087 --- /dev/null +++ b/modules/mesh-mail-web/files/oidc.json @@ -0,0 +1,5 @@ +{ + "client-id": "${bound:oidc-client:as}", + "client-secret": "${secret:oidc-client}", + "issuer": "${bound:oidc-client:issuer}" +} diff --git a/modules/mesh-mail-web/files/operators.json b/modules/mesh-mail-web/files/operators.json new file mode 100644 index 00000000..dea781c4 --- /dev/null +++ b/modules/mesh-mail-web/files/operators.json @@ -0,0 +1,3 @@ +{ + "operators": [] +} diff --git a/modules/mesh-mail-web/go.mod b/modules/mesh-mail-web/go.mod new file mode 100644 index 00000000..baf38bd6 --- /dev/null +++ b/modules/mesh-mail-web/go.mod @@ -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 diff --git a/modules/mesh-mail-web/go.sum b/modules/mesh-mail-web/go.sum new file mode 100644 index 00000000..25aaa963 --- /dev/null +++ b/modules/mesh-mail-web/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-web/module.json b/modules/mesh-mail-web/module.json new file mode 100644 index 00000000..544ed697 --- /dev/null +++ b/modules/mesh-mail-web/module.json @@ -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" + } + ] + } +}