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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A module can now write into its app's notification feed. The design mirrors `ms.

### 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**.
- **`ms.UserID(ctx)` and `ms.AppRole(ctx)`** — trusted identity accessors completing the read surface next to `ms.AppID` — together with `auth.Get` (the full-`Identity` read) the ONLY correct way for module code to read the request's identity: always the context, never the `X-MS-*` headers. All three resolve from the context identity the SDK promotes on every guarded surface (Platform via `PlatformAuth`, Public via the proxy guard's validated-token path, Lambda/task via the typed envelope through `runtime.InjectResources`). Reading the `X-MS-*` headers (`auth.HeaderUserID` / `auth.HeaderAppRole`) instead is the footgun these close: headers exist on the dev tunnel but the deployed Lambda shim **strips** every client-settable identity header, so header reads silently break in production — the exact bug shipped in ms-app-modules#30, now pinned by a regression test. `""` is a legitimate return (internal/system/cron/task calls carry no user; an anonymous Public request may carry an empty user id). The `auth.Header*` constants remain exported as the internal platform-to-SDK wire; their docs now say so.

## [v0.2.6] - 2026-06-20

Expand Down
5 changes: 5 additions & 0 deletions auth/context.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// Package auth provides authentication context and middleware for MirrorStack modules.
//
// Module code reads the request identity from the CONTEXT — ms.UserID /
// ms.AppID / ms.AppRole (or auth.Get) — never from the X-MS-* Header*
// constants, which are the internal platform-to-SDK wire; see the Header*
// docs for why header reads silently break deployed.
package auth

import "context"
Expand Down
17 changes: 13 additions & 4 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@ import (
"github.com/mirrorstack-ai/app-module-sdk/internal/lambdaenv"
)

// Trusted-forwarder identity-injection headers. A caller proves it's a
// trusted forwarder (the platform or its dispatch) by sending a valid
// X-MS-Internal-Secret; if proven, PlatformAuth trusts the user identity
// those callers assert via these headers.
// Trusted-forwarder identity-injection headers — the INTERNAL wire between
// the platform (dispatch/tunnel) and this SDK's middleware, NOT a module
// identity API. A caller proves it's a trusted forwarder (the platform or its
// dispatch) by sending a valid X-MS-Internal-Secret; if proven, PlatformAuth
// trusts the user identity those callers assert via these headers.
//
// Module code must NEVER read the identity-claim headers (HeaderUserID /
// HeaderAppID / HeaderAppRole) directly: the deployed Lambda shim strips
// every client-settable X-MS-* identity header before the router runs
// (trusted identity rides the typed invoke payload instead), so a header
// read works under the dev tunnel and silently breaks deployed — the exact
// bug shipped in ms-app-modules#30. Read identity via ms.UserID / ms.AppID /
// ms.AppRole (or auth.Get), which resolve on every ingestion path.
const (
HeaderInternalSecret = "X-MS-Internal-Secret"
HeaderPlatformToken = "X-MS-Platform-Token"
Expand Down
210 changes: 210 additions & 0 deletions identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package mirrorstack_test

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

ms "github.com/mirrorstack-ai/app-module-sdk"
"github.com/mirrorstack-ai/app-module-sdk/auth"
"github.com/mirrorstack-ai/app-module-sdk/internal/runtime"
)

// TestIdentityAccessors_ContextReads pins the unit contract of ms.UserID and
// ms.AppRole against each identity state a context can carry.
func TestIdentityAccessors_ContextReads(t *testing.T) {
cases := []struct {
name string
ctx context.Context
wantUser, wantRole string
}{
{
name: "no identity set returns empty strings",
ctx: context.Background(),
},
{
name: "reads the fields from the context identity",
ctx: auth.Set(context.Background(), auth.Identity{
UserID: "u-1",
AppID: "app-7",
AppRole: auth.RoleAdmin,
}),
wantUser: "u-1",
wantRole: auth.RoleAdmin,
},
{
// Anonymous Public requests and internal/system/cron/task calls
// carry an identity with no user — a valid state, not an error.
name: "empty fields inside a set identity are legitimate",
ctx: auth.Set(context.Background(), auth.Identity{AppID: "app-7"}),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ms.UserID(tc.ctx); got != tc.wantUser {
t.Errorf("UserID = %q, want %q", got, tc.wantUser)
}
if got := ms.AppRole(tc.ctx); got != tc.wantRole {
t.Errorf("AppRole = %q, want %q", got, tc.wantRole)
}
})
}
}

// TestIdentityAccessors_EnvelopeInjectPath pins that all three accessors
// resolve identity injected by runtime.InjectResources — the shared trusted
// gate the Lambda handler AND the task worker feed from the typed envelope.
func TestIdentityAccessors_EnvelopeInjectPath(t *testing.T) {
ctx, err := runtime.InjectResources(context.Background(), runtime.InjectParams{
UserID: "u-envelope",
AppID: "app-envelope",
AppRole: auth.RoleMember,
})
if err != nil {
t.Fatalf("InjectResources: %v", err)
}

if got := ms.UserID(ctx); got != "u-envelope" {
t.Errorf("UserID = %q, want u-envelope", got)
}
if got := ms.AppID(ctx); got != "app-envelope" {
t.Errorf("AppID = %q, want app-envelope", got)
}
if got := ms.AppRole(ctx); got != auth.RoleMember {
t.Errorf("AppRole = %q, want %q", got, auth.RoleMember)
}
}

// TestIdentityAccessors_GuardHeaderPaths pins that all three accessors
// resolve identity ingested from validated trusted-forwarder headers (the dev
// tunnel / dispatch path) on both HTTP guard surfaces: Platform via
// PlatformAuth and Public via the proxy guard's validated-token path.
func TestIdentityAccessors_GuardHeaderPaths(t *testing.T) {
cases := []struct {
name string
internalSecret string // MS_INTERNAL_SECRET (empty avoids ambient cross-talk)
platformToken string // MS_PLATFORM_TOKEN (empty avoids ambient cross-talk)
guard func() func(http.Handler) http.Handler
proofHeader string // header proving trusted-forwarder status
proofValue string
path string
user, app string
}{
{
name: "platform surface via PlatformAuth",
internalSecret: "test-secret",
guard: auth.PlatformAuth,
proofHeader: auth.HeaderInternalSecret,
proofValue: "test-secret",
path: "/platform/users",
user: "u-tunnel",
app: "app-tunnel",
},
{
name: "public surface via RequireProxy validated-token path",
platformToken: "test-token",
guard: auth.RequireProxy,
proofHeader: auth.HeaderPlatformToken,
proofValue: "test-token",
path: "/public/me",
user: "u-pub",
app: "app-pub",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("MS_INTERNAL_SECRET", tc.internalSecret)
t.Setenv("MS_PLATFORM_TOKEN", tc.platformToken)

var gotUser, gotApp, gotRole string
handler := tc.guard()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotUser = ms.UserID(r.Context())
gotApp = ms.AppID(r.Context())
gotRole = ms.AppRole(r.Context())
}))

req := httptest.NewRequest("GET", tc.path, nil)
req.Header.Set(tc.proofHeader, tc.proofValue)
req.Header.Set(auth.HeaderUserID, tc.user)
req.Header.Set(auth.HeaderAppID, tc.app)
req.Header.Set(auth.HeaderAppRole, auth.RoleViewer)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if gotUser != tc.user || gotApp != tc.app || gotRole != auth.RoleViewer {
t.Errorf("accessors = %q/%q/%q, want %s/%s/%s",
gotUser, gotApp, gotRole, tc.user, tc.app, auth.RoleViewer)
}
})
}
}

// TestIdentityAccessors_DeployedPath_HeadersStrippedCtxResolves pins the
// ms-app-modules#30 footgun as a test: on the deployed Lambda path the shim
// STRIPS the client-settable X-MS-* identity headers, so a module that reads
// r.Header.Get(auth.Header*) gets "" — while ms.UserID / ms.AppID / ms.AppRole
// still resolve the trusted identity from the typed invoke payload. Reading
// headers works on the tunnel and silently breaks deployed; the ctx accessors
// work on both.
func TestIdentityAccessors_DeployedPath_HeadersStrippedCtxResolves(t *testing.T) {
var (
headerUser, headerApp, headerRole string
ctxUser, ctxApp, ctxRole string
)
mux := http.NewServeMux()
mux.HandleFunc("/whoami", func(w http.ResponseWriter, r *http.Request) {
headerUser = r.Header.Get(auth.HeaderUserID)
headerApp = r.Header.Get(auth.HeaderAppID)
headerRole = r.Header.Get(auth.HeaderAppRole)
ctxUser = ms.UserID(r.Context())
ctxApp = ms.AppID(r.Context())
ctxRole = ms.AppRole(r.Context())
})

handler := runtime.NewLambdaHandler(mux)
payload, err := json.Marshal(runtime.LambdaRequest{
Method: "GET",
Path: "/whoami",
// Spoofed identity claims riding the forwarded headers — the shim
// must drop every one of them.
Headers: map[string]string{
auth.HeaderUserID: "spoofed-user",
auth.HeaderAppID: "spoofed-app",
auth.HeaderAppRole: auth.RoleAdmin,
},
// Trusted identity — typed envelope fields injected by the platform.
UserID: "u-payload",
AppID: "app-payload",
AppRole: auth.RoleMember,
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}

resp, err := handler(context.Background(), payload)
if err != nil {
t.Fatalf("lambda handler: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d (body %q)", resp.StatusCode, resp.Body)
}

if headerUser != "" || headerApp != "" || headerRole != "" {
t.Errorf("X-MS-* identity headers must be stripped on the deployed path, got %q/%q/%q",
headerUser, headerApp, headerRole)
}
if ctxUser != "u-payload" {
t.Errorf("UserID = %q, want u-payload (typed payload identity)", ctxUser)
}
if ctxApp != "app-payload" {
t.Errorf("AppID = %q, want app-payload (typed payload identity)", ctxApp)
}
if ctxRole != auth.RoleMember {
t.Errorf("AppRole = %q, want %q (typed payload identity)", ctxRole, auth.RoleMember)
}
}
35 changes: 27 additions & 8 deletions internal/core/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,7 @@ func resolveCallURL(targetModuleID, path string) string {
//
// DEV/DISPATCH TRANSPORT — see resolveCallURL for the prod (#146) seam.
func (m *Module) Call(ctx context.Context, targetModuleID, method, path string, body, out any) error {
appID := ""
if a := auth.Get(ctx); a != nil {
appID = a.AppID
}
appID := AppID(ctx)

var reader io.Reader
if body != nil {
Expand Down Expand Up @@ -129,6 +126,15 @@ func CallPost(ctx context.Context, targetModuleID, path string, body, out any) e
return mustDefault("CallPost").CallPost(ctx, targetModuleID, path, body, out)
}

// identity returns the context's auth identity, or the zero Identity when
// none is set, so field reads on it degrade to "".
func identity(ctx context.Context) auth.Identity {
if a := auth.Get(ctx); a != nil {
return *a
}
return auth.Identity{}
}

// AppID returns the app id from the request context's auth identity, or "" if
// no identity is set. It is the inbound twin of WithAppID and the single
// unspoofable way a handler reads its own app: the SDK promotes the trusted,
Expand All @@ -139,8 +145,21 @@ func CallPost(ctx context.Context, targetModuleID, path string, body, out any) e
// Not module-bound (no *Module receiver) — identity lives on the context, so
// this works before Init and in tests.
func AppID(ctx context.Context) string {
if a := auth.Get(ctx); a != nil {
return a.AppID
}
return ""
return identity(ctx).AppID
}

// UserID returns the user id from the request context's auth identity, or ""
// if no identity is set. Same promotion paths and context-not-module shape as
// AppID; "" is legitimate on surfaces with no user (internal/system/cron/task
// invocations, anonymous Public requests).
func UserID(ctx context.Context) string {
return identity(ctx).UserID
}

// AppRole returns the app role from the request context's auth identity, or
// "" if no identity is set. Same promotion paths and context-not-module shape
// as AppID; "" is legitimate on surfaces with no user (internal/system/cron/
// task invocations, anonymous Public requests).
func AppRole(ctx context.Context) string {
return identity(ctx).AppRole
}
1 change: 0 additions & 1 deletion internal/core/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package core

import (
"net/http"

)

// cronPathPrefix mounts under the reserved /__mirrorstack/ namespace.
Expand Down
1 change: 0 additions & 1 deletion internal/core/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package core
import (
"net/http"


"github.com/mirrorstack-ai/app-module-sdk/internal/registry"
)

Expand Down
1 change: 0 additions & 1 deletion internal/core/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"net/http"
"time"


"github.com/mirrorstack-ai/app-module-sdk/auth"
"github.com/mirrorstack-ai/app-module-sdk/cache"
"github.com/mirrorstack-ai/app-module-sdk/db"
Expand Down
12 changes: 6 additions & 6 deletions internal/registry/exposure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@ func TestAddExposedTable_PanicsOnInvalidName(t *testing.T) {
t.Parallel()

bad := []string{
"", // empty
"Orders", // uppercase
"1orders", // leading digit
"my orders", // whitespace
"", // empty
"Orders", // uppercase
"1orders", // leading digit
"my orders", // whitespace
"orders-table", // hyphen (not a Postgres-safe bare identifier)
"mod.orders", // dot / schema-qualified
"../etc", // path traversal shape
"mod.orders", // dot / schema-qualified
"../etc", // path traversal shape
}
for _, name := range bad {
name := name
Expand Down
8 changes: 4 additions & 4 deletions internal/runtime/lambda_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,10 @@ func TestNewLambdaHandler_AuthSecretHeadersSurvive(t *testing.T) {
Path: "/check",
Headers: map[string]string{
auth.HeaderInternalSecret: "platform-secret", // exempt — must survive
auth.HeaderPlatformToken: "platform-token", // exempt — must survive
auth.HeaderUserID: "spoofed-user", // claim — must be stripped
auth.HeaderAppID: "spoofed-app", // claim — must be stripped
auth.HeaderAppRole: "admin", // claim — must be stripped
auth.HeaderPlatformToken: "platform-token", // exempt — must survive
auth.HeaderUserID: "spoofed-user", // claim — must be stripped
auth.HeaderAppID: "spoofed-app", // claim — must be stripped
auth.HeaderAppRole: "admin", // claim — must be stripped
},
})

Expand Down
Loading
Loading