From 8e753fe79fbff0d2b38ffa4d7508b265ff35a20c Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Thu, 2 Jul 2026 21:03:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ms.Notify=20=E2=80=94=20module-originat?= =?UTF-8?q?ed=20user=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ms.Notify(ctx, ms.Notification{...}) posts a notification envelope to the platform dispatch (/apps/{appID}/notifications), mirroring ms.Emit's transport exactly (shared callHTTP, ctx-derived app id, #146 prod seam on the resolver, non-2xx -> truncated error). Title/Body are i18n Labels resolved to per-locale maps at send so the platform renders each recipient's language; audience is admins (default) or members — the platform re-derives sender identity and expands recipients itself. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + examples/template/notifications.go | 46 +++++ internal/core/call.go | 8 +- internal/core/dispatch_transport.go | 72 +++++++ internal/core/emit.go | 55 +----- internal/core/notify.go | 139 ++++++++++++++ internal/core/notify_test.go | 278 ++++++++++++++++++++++++++++ mirrorstack.go | 40 ++++ 8 files changed, 589 insertions(+), 54 deletions(-) create mode 100644 examples/template/notifications.go create mode 100644 internal/core/dispatch_transport.go create mode 100644 internal/core/notify.go create mode 100644 internal/core/notify_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb4921..a00ff59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +A module can now write into its app's notification feed. The design mirrors `ms.Emit` exactly — same context-derived app scope, same dispatch-HTTP transport, same #146 prod seam — so notifications inherit the trust model already proven for events: the module supplies content, the dispatch re-derives the sender identity from the live session and never trusts the envelope. + +### Added +- **`ms.Notify(ctx, ms.Notification{...})`** — sends an in-app notification to the current app's members. `Notification` carries an i18n `Title` (required) and `Body` (optional) as `ms.Text`/`ms.T` Labels resolved to per-locale maps **at send time** (the platform picks each recipient's locale, the module never does), plus optional `Icon`/`Link` and an `Audience` (`ms.NotifyAdmins`, the default, or `ms.NotifyAllMembers`; anything else is an error). The SDK POSTs a `{id, sentAt, sourceModuleID, title, body, icon, link, audience}` envelope to the platform dispatch notification ingress at `{MS_DISPATCH_URL | dev fallback}/apps/{appID}/notifications` — the same transport idiom as `ms.Emit`/`ms.Call`, with the same error contract: an empty app-scope context, a Title that resolves to no message, or a non-2xx dispatch response (body truncated to ~2 KB) is a **returned error, never a panic**. + ## [v0.2.6] - 2026-06-20 Prepares the SDK for the production module transport. In production a module runs as a Lambda function invoked via the HTTP-shaped `LambdaRequest` envelope; this closes the one kernel gap on that receive path so a deployed module's internal/MCP auth works. diff --git a/examples/template/notifications.go b/examples/template/notifications.go new file mode 100644 index 0000000..8fbdc50 --- /dev/null +++ b/examples/template/notifications.go @@ -0,0 +1,46 @@ +package main + +// CLI flag: --use-notifications +// Remove this file if the module doesn't send in-app notifications. +// +// ms.Notify writes a notification into the current app's feed. Title/Body are +// i18n Labels (ms.Text literal or ms.T catalog key, backed by ms.RegisterMessages) +// resolved to per-locale maps AT SEND TIME, so the platform picks each +// recipient's locale — the module never picks one. Audience targets the app's +// admins (the default) or every member; the platform re-derives the sender +// module from the live session, so the envelope identity is never trusted. + +import ( + "log" + "net/http" + + "github.com/go-chi/chi/v5" + ms "github.com/mirrorstack-ai/app-module-sdk" +) + +func init() { + postInitHooks = append(postInitHooks, registerNotifications) +} + +func registerNotifications() { + ms.Platform(func(r chi.Router) { + r.Post("/reports", func(w http.ResponseWriter, r *http.Request) { + // ... generate the report ... + + // Notify the app's members. The ms.T keys resolve against the + // catalogs loaded via ms.RegisterMessages (i18n/.json), + // e.g. {"notifications":{"report":{"ready":"Report ready"}}}. + // A notification failure must not fail the handler — log it. + if err := ms.Notify(r.Context(), ms.Notification{ + Title: ms.T("notifications.report.ready"), + Body: ms.T("notifications.report.ready_body"), + Icon: "description", + Link: "/reports/latest", + Audience: ms.NotifyAllMembers, // omit to target admins only + }); err != nil { + log.Printf("notify: %v", err) // don't fail the handler + } + w.WriteHeader(http.StatusOK) + }) + }) +} diff --git a/internal/core/call.go b/internal/core/call.go index 851b99e..7a7835f 100644 --- a/internal/core/call.go +++ b/internal/core/call.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "os" "strings" "time" @@ -43,12 +42,7 @@ var callHTTP = &http.Client{Timeout: callTimeout} // lands, swap the body of this function (and only this function) to consult // the catalog/Lambda resolver; Call's marshal/auth/error contract stays put. func resolveCallURL(targetModuleID, path string) string { - base := os.Getenv("MS_DISPATCH_URL") - if base == "" { - base = devDispatchFallback - } - base = strings.TrimRight(base, "/") - return fmt.Sprintf("%s/module/%s%s", base, targetModuleID, path) + return fmt.Sprintf("%s/module/%s%s", dispatchBase(), targetModuleID, path) } // Call makes one server-mediated module-to-module hop through the platform diff --git a/internal/core/dispatch_transport.go b/internal/core/dispatch_transport.go new file mode 100644 index 0000000..a44bcce --- /dev/null +++ b/internal/core/dispatch_transport.go @@ -0,0 +1,72 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/mirrorstack-ai/app-module-sdk/auth" +) + +// This file is the ONE module->dispatch transport every outbound surface +// (Call, Emit, Notify) builds on: base resolution, the app-scope guard, and +// the POST-with-identity-headers/error contract. Each surface keeps only its +// envelope construction and its resolve*URL path building, so the #146 prod +// transport lands here (and in the per-surface resolvers' path logic) instead +// of being swapped in N copies. + +// dispatchBase resolves the platform-dispatch base URL: MS_DISPATCH_URL (the +// container->dispatch base) with the host.docker.internal dev fallback when +// unset. +func dispatchBase() string { + base := os.Getenv("MS_DISPATCH_URL") + if base == "" { + base = devDispatchFallback + } + return strings.TrimRight(base, "/") +} + +// appIDFromContext reads the current app id from the request context — the +// same identity the SDK injects for handlers. An empty app id is an error +// (no panic): every dispatch surface needs an app scope. op names the caller +// ("Emit", "Notify") in the error. +func appIDFromContext(ctx context.Context, op string) (string, error) { + if a := auth.Get(ctx); a != nil && a.AppID != "" { + return a.AppID, nil + } + return "", fmt.Errorf("mirrorstack: %s requires an app-scoped context (no AppID in auth identity)", op) +} + +// postDispatchJSON marshals payload and POSTs it to url with the module-> +// dispatch header set (Content-Type + X-MS-App-ID — no token; the dispatch +// authenticates the sender by transport, never by envelope assertion). A +// non-2xx response is returned as an error prefixed with op ("ms.Emit", +// "ms.Notify"), body truncated to ~2 KB. +func postDispatchJSON(ctx context.Context, op, url, appID string, payload any) error { + buf, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-MS-App-ID", appID) + + resp, err := callHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("%s %s -> %d: %s", op, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(b))) + } + return nil +} diff --git a/internal/core/emit.go b/internal/core/emit.go index 0f44455..d873f06 100644 --- a/internal/core/emit.go +++ b/internal/core/emit.go @@ -1,18 +1,10 @@ package core import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" - "io" - "net/http" - "os" - "strings" "time" - "github.com/mirrorstack-ai/app-module-sdk/auth" "github.com/mirrorstack-ai/app-module-sdk/internal/ids" ) @@ -38,17 +30,11 @@ type eventEnvelope struct { // core.Call uses for inter-module hops. // // DEV/DISPATCH TRANSPORT. Prod event-bus endpoint resolution is task #146 — -// this resolver is the seam where that plugs in, mirroring resolveCallURL. In -// prod the event bus is not a single dev dispatch base; when #146 lands, swap -// the body of this function (and only this function) to consult the prod -// transport. Emit's marshal/auth/error contract stays put. +// this resolver (path) and dispatchBase (base) are the seams where that plugs +// in, mirroring resolveCallURL. Emit's marshal/auth/error contract +// (dispatch_transport.go) stays put. func resolveEventBusURL(appID, name string) string { - base := os.Getenv("MS_DISPATCH_URL") - if base == "" { - base = devDispatchFallback - } - base = strings.TrimRight(base, "/") - return fmt.Sprintf("%s/apps/%s/events/%s", base, appID, name) + return fmt.Sprintf("%s/apps/%s/events/%s", dispatchBase(), appID, name) } // Emit publishes an event to every LIVE module that subscribes to name within @@ -70,12 +56,9 @@ func resolveEventBusURL(appID, name string) string { // // DEV/DISPATCH TRANSPORT — see resolveEventBusURL for the prod (#146) seam. func (m *Module) Emit(ctx context.Context, name string, payload any) error { - appID := "" - if a := auth.Get(ctx); a != nil { - appID = a.AppID - } - if appID == "" { - return errors.New("mirrorstack: Emit requires an app-scoped context (no AppID in auth identity)") + appID, err := appIDFromContext(ctx, "Emit") + if err != nil { + return err } env := eventEnvelope{ @@ -85,29 +68,7 @@ func (m *Module) Emit(ctx context.Context, name string, payload any) error { SentAt: time.Now().UTC().Format(time.RFC3339Nano), Payload: payload, } - buf, err := json.Marshal(env) - if err != nil { - return err - } - - u := resolveEventBusURL(appID, name) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(buf)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-MS-App-ID", appID) - - resp, err := callHTTP.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return fmt.Errorf("ms.Emit %s -> %d: %s", req.URL.Path, resp.StatusCode, strings.TrimSpace(string(b))) - } - return nil + return postDispatchJSON(ctx, "ms.Emit", resolveEventBusURL(appID, name), appID, env) } // Emit publishes an event on the default module created by Init(). Panics diff --git a/internal/core/notify.go b/internal/core/notify.go new file mode 100644 index 0000000..f4c5346 --- /dev/null +++ b/internal/core/notify.go @@ -0,0 +1,139 @@ +package core + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/mirrorstack-ai/app-module-sdk/internal/ids" +) + +// NotifyAudience selects WHO inside the app receives a notification. The +// value travels on the wire (the envelope's "audience" field), so the strings +// are a SHARED CONTRACT with the dispatch's audience whitelist. +type NotifyAudience string + +const ( + // NotifyAdmins targets the app's admins only — the default when a + // Notification leaves Audience unset. + NotifyAdmins NotifyAudience = "admins" + // NotifyAllMembers targets every member of the app. + NotifyAllMembers NotifyAudience = "members" +) + +// Notification is the module-facing shape passed to Notify. Title and Body +// are i18n Labels (Text literal or T catalog key) resolved to per-locale maps +// AT SEND TIME, so the platform — not the module — picks the recipient's +// locale. Title is required; Body, Icon and Link are optional. An unset +// Audience defaults to NotifyAdmins. +type Notification struct { + Title Label + Body Label + Icon string + Link string + Audience NotifyAudience +} + +// notifyEnvelope is the wire shape every notification takes. The dispatch's +// POST /apps/{appID}/notifications route decodes this struct, so the JSON +// field names here are a SHARED CONTRACT with the dispatch — don't rename +// them without changing both sides. +type notifyEnvelope struct { + ID string `json:"id"` // per-send UUID (idempotency key) + SentAt string `json:"sentAt"` // RFC3339 UTC timestamp of the send + SourceModuleID string `json:"sourceModuleID"` // sending module's Config.ID + Title map[string]string `json:"title"` // locale -> title (Label.Resolve) + Body map[string]string `json:"body"` // locale -> body; empty map when Body is unset + Icon string `json:"icon"` // optional icon name + Link string `json:"link"` // optional in-app link target + Audience NotifyAudience `json:"audience"` // "admins" | "members" +} + +// resolveNotifyURL builds the platform-dispatch URL the envelope is POSTed to: +// +// {base}/apps/{appID}/notifications +// +// DEV/DISPATCH TRANSPORT. Prod notification-ingress resolution is task #146 — +// this resolver (path) and dispatchBase (base) are the seams where that plugs +// in, mirroring resolveEventBusURL. Notify's marshal/auth/error contract +// (dispatch_transport.go) stays put. +func resolveNotifyURL(appID string) string { + return fmt.Sprintf("%s/apps/%s/notifications", dispatchBase(), appID) +} + +// Notify sends an in-app notification to the current app's members. It +// resolves the Notification's i18n Labels to per-locale maps, wraps them in +// the notification envelope (a fresh UUID id, the sending module's Config.ID +// as sourceModuleID, and an RFC3339 UTC sentAt), then POSTs the envelope to +// the platform dispatch notification ingress scoped to the current app. The +// dispatch re-derives the sender identity from the live session — the +// envelope's sourceModuleID is informational, never trusted. +// +// The app id is read from the request context (auth.Get) — the same identity +// the SDK injects for handlers — and is encoded in the dispatch URL. An empty +// app id is an error (no panic): Notify needs an app scope to know whose feed +// receives the notification. +// +// Title is REQUIRED — a Title that resolves to no message (unset, or an empty +// literal) is an error (no panic). Body is optional (an unset Body sends an +// empty map). An unset Audience defaults to +// NotifyAdmins; any value other than NotifyAdmins/NotifyAllMembers is an +// error. A non-2xx response from the dispatch is returned as an error with +// the response body truncated to ~2 KB. +// +// DEV/DISPATCH TRANSPORT — see resolveNotifyURL for the prod (#146) seam. +func (m *Module) Notify(ctx context.Context, n Notification) error { + appID, err := appIDFromContext(ctx, "Notify") + if err != nil { + return err + } + + title := n.Title.Resolve() + if !hasMessage(title) { + return errors.New("mirrorstack: Notify requires a Title (ms.Text or ms.T)") + } + + audience := n.Audience + if audience == "" { + audience = NotifyAdmins + } + if audience != NotifyAdmins && audience != NotifyAllMembers { + return fmt.Errorf("mirrorstack: Notify audience %q is not %q or %q", audience, NotifyAdmins, NotifyAllMembers) + } + + body := map[string]string{} + if !n.Body.IsZero() { + body = n.Body.Resolve() + } + env := notifyEnvelope{ + ID: ids.NewUUID(), + SentAt: time.Now().UTC().Format(time.RFC3339Nano), + SourceModuleID: m.config.ID, + Title: title, + Body: body, + Icon: n.Icon, + Link: n.Link, + Audience: audience, + } + return postDispatchJSON(ctx, "ms.Notify", resolveNotifyURL(appID), appID, env) +} + +// hasMessage reports whether a resolved locale map carries at least one +// non-empty message. An unset Label resolves to {DefaultLocale: ""}, so a map +// with only empty values means the caller never gave the field a value. +// (Deliberately stricter than Label.IsZero, which admits ms.Text("").) +func hasMessage(resolved map[string]string) bool { + for _, msg := range resolved { + if msg != "" { + return true + } + } + return false +} + +// Notify sends a notification on the default module created by Init(). Panics +// before Init — matches Call/CallGet/CallPost/Emit. +func Notify(ctx context.Context, n Notification) error { + return mustDefault("Notify").Notify(ctx, n) +} diff --git a/internal/core/notify_test.go b/internal/core/notify_test.go new file mode 100644 index 0000000..2e2b5a7 --- /dev/null +++ b/internal/core/notify_test.go @@ -0,0 +1,278 @@ +package core + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mirrorstack-ai/app-module-sdk/auth" +) + +func TestResolveNotifyURL_Building(t *testing.T) { + cases := []struct { + name string + dispatch string // value for MS_DISPATCH_URL ("" = unset -> dev fallback) + appID string + want string + }{ + { + name: "dev fallback when unset", + dispatch: "", + appID: "a-456", + want: devDispatchFallback + "/apps/a-456/notifications", + }, + { + name: "explicit base", + dispatch: "http://dispatch:8083", + appID: "a-456", + want: "http://dispatch:8083/apps/a-456/notifications", + }, + { + name: "trailing slash on base is trimmed", + dispatch: "http://dispatch:8083/", + appID: "a-456", + want: "http://dispatch:8083/apps/a-456/notifications", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MS_DISPATCH_URL", tc.dispatch) + if got := resolveNotifyURL(tc.appID); got != tc.want { + t.Errorf("resolveNotifyURL(%q) = %q, want %q", tc.appID, got, tc.want) + } + }) + } +} + +// TestNotify_EnvelopeContract pins the wire shape: the JSON field names are a +// SHARED CONTRACT with the dispatch's POST /apps/{appID}/notifications route. +func TestNotify_EnvelopeContract(t *testing.T) { + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + t.Setenv("MS_DISPATCH_URL", srv.URL) + + m, _ := New(Config{ID: "billing"}) + ctx := auth.Set(context.Background(), auth.Identity{AppID: "a-456"}) + + err := m.Notify(ctx, Notification{ + Title: Text("Order placed"), + Body: Text("Order #42 was placed."), + Icon: "shopping_cart", + Link: "/orders/42", + Audience: NotifyAllMembers, + }) + if err != nil { + t.Fatalf("Notify: %v", err) + } + + // Exact field-name check: unmarshal into a raw map so a renamed or + // missing key fails loudly instead of zero-valuing. + var raw map[string]json.RawMessage + if err := json.Unmarshal(gotBody, &raw); err != nil { + t.Fatalf("envelope unmarshal: %v (body=%s)", err, gotBody) + } + for _, key := range []string{"id", "sentAt", "sourceModuleID", "title", "body", "icon", "link", "audience"} { + if _, ok := raw[key]; !ok { + t.Errorf("envelope missing field %q (body=%s)", key, gotBody) + } + } + if len(raw) != 8 { + t.Errorf("envelope has %d fields, want 8 (body=%s)", len(raw), gotBody) + } + + var env struct { + ID string `json:"id"` + SentAt string `json:"sentAt"` + SourceModuleID string `json:"sourceModuleID"` + Title map[string]string `json:"title"` + Body map[string]string `json:"body"` + Icon string `json:"icon"` + Link string `json:"link"` + Audience string `json:"audience"` + } + if err := json.Unmarshal(gotBody, &env); err != nil { + t.Fatalf("envelope unmarshal: %v (body=%s)", err, gotBody) + } + if env.ID == "" { + t.Error("envelope id is empty, want a generated UUID") + } + if env.SentAt == "" { + t.Error("envelope sentAt is empty, want an RFC3339 timestamp") + } + if env.SourceModuleID != "billing" { + t.Errorf("envelope sourceModuleID = %q, want billing (Config.ID)", env.SourceModuleID) + } + // Text() literals resolve under the default locale. + if got := env.Title["en-US"]; got != "Order placed" { + t.Errorf("envelope title[en-US] = %q, want Order placed", got) + } + if got := env.Body["en-US"]; got != "Order #42 was placed." { + t.Errorf("envelope body[en-US] = %q, want Order #42 was placed.", got) + } + if env.Icon != "shopping_cart" { + t.Errorf("envelope icon = %q, want shopping_cart", env.Icon) + } + if env.Link != "/orders/42" { + t.Errorf("envelope link = %q, want /orders/42", env.Link) + } + if env.Audience != "members" { + t.Errorf("envelope audience = %q, want members", env.Audience) + } +} + +func TestNotify_PostsFromContextAppID(t *testing.T) { + var gotMethod, gotPath, gotAppID, gotCT string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAppID = r.Header.Get("X-MS-App-ID") + gotCT = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + t.Setenv("MS_DISPATCH_URL", srv.URL) + + m, _ := New(Config{ID: "billing"}) + ctx := auth.Set(context.Background(), auth.Identity{AppID: "a-456"}) + + if err := m.Notify(ctx, Notification{Title: Text("Hello")}); err != nil { + t.Fatalf("Notify: %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want POST", gotMethod) + } + // appID is taken from ctx via auth.Set, encoded into both URL and header. + if gotPath != "/apps/a-456/notifications" { + t.Errorf("path = %q, want /apps/a-456/notifications", gotPath) + } + if gotAppID != "a-456" { + t.Errorf("X-MS-App-ID = %q, want a-456", gotAppID) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotCT) + } + + var env struct { + Body map[string]string `json:"body"` + Audience string `json:"audience"` + } + if err := json.Unmarshal(gotBody, &env); err != nil { + t.Fatalf("envelope unmarshal: %v (body=%s)", err, gotBody) + } + // Defaults: unset Audience -> admins; unset Body -> empty map (not null). + if env.Audience != "admins" { + t.Errorf("envelope audience = %q, want admins (default)", env.Audience) + } + if env.Body == nil || len(env.Body) != 0 { + t.Errorf("envelope body = %v, want empty map for unset Body", env.Body) + } +} + +func TestNotify_ValidationErrorsWithoutHTTP(t *testing.T) { + var hit bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = true + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + t.Setenv("MS_DISPATCH_URL", srv.URL) + + m, _ := New(Config{ID: "billing"}) + appCtx := auth.Set(context.Background(), auth.Identity{AppID: "a-456"}) + + cases := []struct { + name string + ctx context.Context + n Notification + }{ + { + // No auth identity on the context -> no AppID. + name: "empty app context", + ctx: context.Background(), + n: Notification{Title: Text("Hello")}, + }, + { + name: "unset Title", + ctx: appCtx, + n: Notification{Body: Text("body only")}, + }, + { + name: "empty-literal Title", + ctx: appCtx, + n: Notification{Title: Text("")}, + }, + { + name: "unknown audience", + ctx: appCtx, + n: Notification{Title: Text("Hello"), Audience: NotifyAudience("everyone")}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := m.Notify(tc.ctx, tc.n); err == nil { + t.Fatal("expected error, got nil") + } + if hit { + t.Error("Notify made an HTTP call despite failing validation") + } + }) + } +} + +func TestNotify_Non2xxReturnsErrorWithTruncatedBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("notification ingress unavailable")) + })) + defer srv.Close() + t.Setenv("MS_DISPATCH_URL", srv.URL) + + m, _ := New(Config{ID: "billing"}) + ctx := auth.Set(context.Background(), auth.Identity{AppID: "a-456"}) + + err := m.Notify(ctx, Notification{Title: Text("Hello")}) + if err == nil { + t.Fatal("expected error on non-2xx, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "502") { + t.Errorf("error %q missing status 502", msg) + } + if !strings.Contains(msg, "notification ingress unavailable") { + t.Errorf("error %q missing upstream body", msg) + } + if !strings.Contains(msg, "/apps/a-456/notifications") { + t.Errorf("error %q missing request path", msg) + } +} + +func TestNotify_Non2xxBodyIsTruncatedTo2KB(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(strings.Repeat("x", 10_000))) + })) + defer srv.Close() + t.Setenv("MS_DISPATCH_URL", srv.URL) + + m, _ := New(Config{ID: "billing"}) + ctx := auth.Set(context.Background(), auth.Identity{AppID: "a-456"}) + + err := m.Notify(ctx, Notification{Title: Text("Hello")}) + if err == nil { + t.Fatal("expected error on non-2xx, got nil") + } + if got := len(err.Error()); got > 2048+128 { // 2KB body + message framing + t.Errorf("error message is %d bytes, want upstream body truncated to ~2KB", got) + } +} diff --git a/mirrorstack.go b/mirrorstack.go index 2c3cb9d..84995c4 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -250,6 +250,46 @@ func Emit(ctx context.Context, name string, payload any) error { return core.Emit(ctx, name, payload) } +// Notification is the shape passed to Notify: an i18n Title (required) and +// Body (optional) — Labels built from ms.Text / ms.T, resolved to per-locale +// maps at send time so the platform picks the recipient's locale — plus an +// optional Icon and in-app Link, and the target Audience (defaults to +// NotifyAdmins when unset). +type Notification = core.Notification + +// NotifyAudience selects WHO inside the app receives a notification: +// NotifyAdmins (the default) or NotifyAllMembers. +type NotifyAudience = core.NotifyAudience + +// NotifyAdmins and NotifyAllMembers are the notification audiences. They are +// CONSTANTS, not vars, so a third-party module cannot reassign them (the SDK +// is a trust boundary). +const ( + NotifyAdmins = core.NotifyAdmins + NotifyAllMembers = core.NotifyAllMembers +) + +// Notify sends an in-app notification to the current app's members, scoped to +// the current app (app id read from ctx via the SDK's auth identity — callers +// don't pass it). The SDK resolves the Notification's Labels, wraps them in +// the notification envelope ({id, sentAt, sourceModuleID, title, body, icon, +// link, audience}) and POSTs it to the platform dispatch notification ingress, +// which re-derives the sender identity from the live session and writes the +// notification into the app's feed. An empty app-scope context, an unset +// Title, or an unknown Audience is an error (no panic). +// +// ms.Notify(r.Context(), ms.Notification{ +// Title: ms.T("notifications.order.placed"), +// Link: "/orders/42", +// Audience: ms.NotifyAllMembers, +// }) +// +// Dev/dispatch transport today; prod notification-ingress resolution is the +// documented #146 seam (see core.resolveNotifyURL, mirroring core.resolveEventBusURL). +func Notify(ctx context.Context, n Notification) error { + return core.Notify(ctx, n) +} + // AppID returns the current app id from the request context, or "" if no // identity is set. It is the inbound twin of WithAppID and the single // unspoofable way a handler reads its OWN app.