Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions examples/template/notifications.go
Original file line number Diff line number Diff line change
@@ -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/<locale>.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)
})
})
}
8 changes: 1 addition & 7 deletions internal/core/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions internal/core/dispatch_transport.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 8 additions & 47 deletions internal/core/emit.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand All @@ -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
Expand All @@ -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{
Expand All @@ -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
Expand Down
139 changes: 139 additions & 0 deletions internal/core/notify.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading