From afd3277029712eed9a27ee64530d6ae3052c6bab Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 25 May 2026 12:00:36 +0800 Subject: [PATCH 1/2] feat(auth): PlatformAuth trusts identity from forwarder + local-dev bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on the tunnel-auth workstream's SDK slice (memory: feedback_tunnel_auth_no_bypass). PlatformAuth now has three identity sources, in priority order: 1. Identity preset in ctx (Lambda authorizer path — unchanged). 2. Trusted-forwarder injection: a request carrying a valid X-MS-Internal-Secret may also assert user identity via X-MS-User-ID / X-MS-App-ID / X-MS-App-Role headers, which PlatformAuth promotes to ctx.Identity. Same trust signal InternalAuth uses; no second secret needed. 3. Local-dev bypass: when MS_INTERNAL_SECRET is unset AND we're not in Lambda, a synthetic admin identity is injected so `mirrorstack dev` (no tunnel) can render platform-scope routes without the developer wiring auth manually. Matches InternalAuth's local bypass shape. Behavior matrix mirrors InternalAuth: inLambda + secret unset → 503 (operator misconfig) inLambda + secret set → enforce (Lambda authorizer wins if preset) local + secret unset → bypass with synthetic admin local + secret set → enforce (tunnel mode — CLI sets the secret via mirrorstack-cli #33) The trusted-forwarder design unblocks the cascade: - api-platform proxy/dispatch attaches signed headers when forwarding browser requests on behalf of an authenticated platform user → modules see the right Identity automatically. - In local dev with no tunnel, the bypass lets module UIs render against platform-scope routes without coordinating env between web-applications and the running module. Test plan: - All 7 auth.PlatformAuth* tests pass (preset ctx, local bypass, Lambda 503, missing secret, wrong secret, missing identity headers, valid headers → identity injected). - internal/core.TestPlatform_LocalDevBypass_InjectsSyntheticAdmin and TestPlatform_SecretSet_RejectsNoHeader replace the old TestPlatform_RejectsNoAuth, pinning the new contract end-to-end through the SDK's scope-routes plumbing. - Whole-repo `go test ./...` clean except for a pre-existing flake in internal/refcache.TestSlowPath_SingleflightCoalesces (concurrency timing, unrelated to auth). Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/middleware.go | 100 ++++++++++++++++++++++++++- auth/middleware_test.go | 130 ++++++++++++++++++++++++++++++----- internal/core/module_test.go | 28 +++++++- 3 files changed, 235 insertions(+), 23 deletions(-) diff --git a/auth/middleware.go b/auth/middleware.go index 9270cfc..b9f3322 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -10,17 +10,111 @@ 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. +const ( + HeaderInternalSecret = "X-MS-Internal-Secret" + HeaderUserID = "X-MS-User-ID" + HeaderAppID = "X-MS-App-ID" + HeaderAppRole = "X-MS-App-Role" +) + // PlatformAuth returns middleware that requires an authenticated user. // Use RequirePermission per-route for authorization (which roles can access). +// +// Identity sources (first match wins): +// +// 1. An Identity already set in ctx (the Lambda runtime's authorizer +// path puts one here before this middleware runs). +// 2. Trusted-forwarder injection: a request that presents a valid +// X-MS-Internal-Secret may also assert user identity via +// X-MS-User-ID / X-MS-App-ID / X-MS-App-Role. PlatformAuth promotes +// those to ctx.Identity. Same trust signal InternalAuth uses. +// 3. Local-dev bypass: when MS_INTERNAL_SECRET is unset AND we are NOT +// in Lambda, a synthetic admin identity is injected so +// `mirrorstack dev` (no tunnel) can render platform-scope routes +// without the developer wiring auth manually. +// +// In Lambda with no secret configured, this returns 503 (operator +// misconfiguration) to match InternalAuth's behavior. func PlatformAuth() func(http.Handler) http.Handler { + return platformAuth(lambdaenv.IsSet()) +} + +// platformAuth is the test seam; inLambda is injected so tests don't +// mutate process env captured at package init. +// +// Behavior matrix (secret = MS_INTERNAL_SECRET): +// +// inLambda + secret unset → 503 (operator misconfig; platform alerting) +// inLambda + secret set → enforce: validate secret + identity headers +// (Lambda authorizer path also still works — +// if Identity is preset in ctx, that wins) +// local + secret unset → BYPASS: inject synthetic admin identity +// (parallel to InternalAuth's local bypass) +// local + secret set → enforce (e.g. `mirrorstack dev --tunnel` +// where the CLI sets the secret) +func platformAuth(inLambda bool) func(http.Handler) http.Handler { + expected := os.Getenv("MS_INTERNAL_SECRET") + if expected == "" && !inLambda { + log.Printf("mirrorstack: MS_INTERNAL_SECRET not set; platform routes bypass auth with synthetic admin identity (local dev)") + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a := Get(r.Context()) - if a == nil || a.AppRole == "" { + // Step 1: honor identity already attached upstream. + if existing := Get(r.Context()); existing != nil && existing.AppRole != "" { + next.ServeHTTP(w, r) + return + } + + // Step 2: local-dev bypass — inject synthetic admin. + if expected == "" && !inLambda { + ctx := Set(r.Context(), Identity{ + UserID: "local-dev-user", + AppID: "local-dev-app", + AppRole: RoleAdmin, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Step 3: Lambda + no secret is a misconfig. + if expected == "" { + log.Printf("mirrorstack: platform auth rejected (no secret configured) from %s %s", r.RemoteAddr, r.URL.Path) + // SECURITY: generic body — same rationale as InternalAuth's 503. + httputil.JSON(w, http.StatusServiceUnavailable, httputil.ErrorResponse{ + Error: "service unavailable", + }) + return + } + + // Step 4: trusted-forwarder path. Validate secret, then read + // identity headers. + secret := r.Header.Get(HeaderInternalSecret) + if !constantTimeEqual(secret, expected) { + // SECURITY: never log the header value; only presence. + log.Printf("mirrorstack: platform auth rejected (secret mismatch, header_present=%v) from %s %s", secret != "", r.RemoteAddr, r.URL.Path) httputil.JSON(w, http.StatusUnauthorized, httputil.ErrorResponse{Error: "authentication required"}) return } - next.ServeHTTP(w, r) + + userID := r.Header.Get(HeaderUserID) + appID := r.Header.Get(HeaderAppID) + appRole := r.Header.Get(HeaderAppRole) + if userID == "" || appID == "" || appRole == "" { + httputil.JSON(w, http.StatusUnauthorized, httputil.ErrorResponse{Error: "platform identity headers required"}) + return + } + + ctx := Set(r.Context(), Identity{ + UserID: userID, + AppID: appID, + AppRole: appRole, + }) + next.ServeHTTP(w, r.WithContext(ctx)) }) } } diff --git a/auth/middleware_test.go b/auth/middleware_test.go index 816185e..96430d4 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -23,9 +23,68 @@ func requestWithRole(method, path, role string) *http.Request { return req } -func TestPlatformAuth_NoRole(t *testing.T) { - handler := PlatformAuth()(http.HandlerFunc(okHandler)) - req := httptest.NewRequest("GET", "/items", nil) +func TestPlatformAuth_AnyRoleInContextAllowed(t *testing.T) { + // Identity preset upstream (Lambda authorizer path) wins regardless of + // env state, so this test pins down "ctx wins" without caring about + // MS_INTERNAL_SECRET. Forces in-lambda + no secret to prove we honor + // preset Identity even when other branches would 503. + t.Setenv("MS_INTERNAL_SECRET", "") + handler := platformAuth(true)(http.HandlerFunc(okHandler)) + + for _, role := range []string{RoleAdmin, RoleMember, RoleViewer, "VideoManager"} { + req := requestWithRole("GET", "/items", role) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("role %q: expected 200, got %d", role, rec.Code) + } + } +} + +func TestPlatformAuth_LocalBypass_InjectsAdminIdentity(t *testing.T) { + // Local dev with no secret: synthetic admin identity is injected so + // /platform/* routes work in `mirrorstack dev` without tunnel. + t.Setenv("MS_INTERNAL_SECRET", "") + var seen *Identity + handler := platformAuth(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/platform/users", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Errorf("expected 200, got %d", rec.Code) + } + if seen == nil { + t.Fatal("expected synthetic Identity to be injected; got nil") + } + if seen.AppRole != RoleAdmin { + t.Errorf("synthetic identity must be admin so RequirePermission passes; got %q", seen.AppRole) + } +} + +func TestPlatformAuth_NoSecret_InLambda_503(t *testing.T) { + // Lambda + no secret = operator misconfig. Match InternalAuth's 503. + t.Setenv("MS_INTERNAL_SECRET", "") + handler := platformAuth(true)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/platform/users", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", rec.Code) + } +} + +func TestPlatformAuth_SecretSet_NoHeader_401(t *testing.T) { + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + handler := platformAuth(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/platform/users", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -34,30 +93,65 @@ func TestPlatformAuth_NoRole(t *testing.T) { } } -func TestPlatformAuth_AnyRoleAllowed(t *testing.T) { - handler := PlatformAuth()(http.HandlerFunc(okHandler)) +func TestPlatformAuth_SecretSet_WrongSecret_401(t *testing.T) { + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + handler := platformAuth(false)(http.HandlerFunc(okHandler)) - for _, role := range []string{RoleAdmin, RoleMember, RoleViewer} { - req := requestWithRole("GET", "/items", role) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) + req := httptest.NewRequest("GET", "/platform/users", nil) + req.Header.Set(HeaderInternalSecret, "wrong") + req.Header.Set(HeaderUserID, "u-1") + req.Header.Set(HeaderAppID, "a-1") + req.Header.Set(HeaderAppRole, RoleAdmin) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("role %q: expected 200, got %d", role, rec.Code) - } + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) } } -func TestPlatformAuth_CustomRole(t *testing.T) { - handler := PlatformAuth()(http.HandlerFunc(okHandler)) +func TestPlatformAuth_SecretSet_MissingIdentityHeaders_401(t *testing.T) { + // Valid secret but no identity → 401. The trusted-forwarder must + // assert who the user is; we don't fabricate one for them. + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + handler := platformAuth(false)(http.HandlerFunc(okHandler)) - // Custom role passes PlatformAuth (authentication gate — any non-empty role) - req := requestWithRole("GET", "/items", "VideoManager") + req := httptest.NewRequest("GET", "/platform/users", nil) + req.Header.Set(HeaderInternalSecret, "real-secret") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("expected 200 for custom role, got %d", rec.Code) + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "identity headers required") { + t.Errorf("expected explanatory body, got %q", rec.Body.String()) + } +} + +func TestPlatformAuth_SecretSet_ValidHeaders_InjectsIdentity(t *testing.T) { + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + var seen *Identity + handler := platformAuth(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/platform/users", nil) + req.Header.Set(HeaderInternalSecret, "real-secret") + req.Header.Set(HeaderUserID, "u-123") + req.Header.Set(HeaderAppID, "a-456") + req.Header.Set(HeaderAppRole, RoleViewer) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Errorf("expected 200, got %d", rec.Code) + } + if seen == nil { + t.Fatal("expected Identity to be injected from headers; got nil") + } + if seen.UserID != "u-123" || seen.AppID != "a-456" || seen.AppRole != RoleViewer { + t.Errorf("identity mismatch: got %+v", seen) } } diff --git a/internal/core/module_test.go b/internal/core/module_test.go index b1f0eb2..2e2a8a5 100644 --- a/internal/core/module_test.go +++ b/internal/core/module_test.go @@ -217,7 +217,31 @@ func TestRouter(t *testing.T) { // --- Scope auth enforcement --- -func TestPlatform_RejectsNoAuth(t *testing.T) { +func TestPlatform_LocalDevBypass_InjectsSyntheticAdmin(t *testing.T) { + // Local dev + no MS_INTERNAL_SECRET: PlatformAuth injects a synthetic + // admin identity so `mirrorstack dev` (no tunnel) can render + // platform-scope routes without the developer wiring auth. Tunnel + // mode flips this branch off by setting the secret (see + // auth.platformAuth doc-matrix + mirrorstack-cli #33). + t.Setenv("MS_INTERNAL_SECRET", "") + m, _ := New(Config{ID: "test", Name: "Test"}) + m.Platform(func(r chi.Router) { + r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("admin")) + }) + }) + + rec := doRequest(t, m.Router(), "GET", "/admin") + if rec.Code != 200 { + t.Errorf("expected 200 (synthetic admin injected), got %d", rec.Code) + } +} + +func TestPlatform_SecretSet_RejectsNoHeader(t *testing.T) { + // When MS_INTERNAL_SECRET is set (tunnel mode or prod), local bypass + // is off — a Platform-scope route 401s without a valid trusted- + // forwarder header. + t.Setenv("MS_INTERNAL_SECRET", "real-secret") m, _ := New(Config{ID: "test", Name: "Test"}) m.Platform(func(r chi.Router) { r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { @@ -227,7 +251,7 @@ func TestPlatform_RejectsNoAuth(t *testing.T) { rec := doRequest(t, m.Router(), "GET", "/admin") if rec.Code != 401 { - t.Errorf("expected 401 without auth, got %d", rec.Code) + t.Errorf("expected 401 without trusted-forwarder header, got %d", rec.Code) } } From e9cbf1fd355e7589c937c4b52c7b4bd831ad70aa Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 25 May 2026 17:56:02 +0800 Subject: [PATCH 2/2] feat(contributions): host-side contribution slots + local-dev CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions, both layered on the existing manifest/auth scaffolding in this branch (PR #103 + #104 land first): 1. internal/contributions/ — NEW package - Slot, Registry, Storage, Handlers types. - SDK auto-mounts under /__mirrorstack/contrib: POST // register/upsert (Internal-scoped) DELETE // unregister (404 on miss; idempotency at the HTTP layer is a no — callers see 404 on double-delete so they know it landed) GET / list (newest-first, LIMIT 1000 cap so runaway contributors can't blow up response size) - Per-module table _contributions (jsonb payload + slot/id PK), auto-CREATE TABLE IF NOT EXISTS on Start when any slot is declared; prod schema management belongs to the lifecycle install hook in a follow-up. - DefineContribute[T] generic API. The validator runs json.Decoder.DisallowUnknownFields() so an unknown field gets rejected at register time instead of polluting the jsonb column. - Table name is pgx.Identifier.Sanitize'd once at NewStorage, matches the SDK's convention for identifier interpolation in handler-written SQL. - Manifest grows a DefinedContributions []SlotInfo field so the catalog can introspect which slots a host accepts. - mirrorstack.go exports DefineContribute[T] and Contributions(). 2. internal/core/module.go — local-dev CORS middleware - Installs an origin-echoing CORS layer on the router only when MS_INTERNAL_SECRET is unset (i.e. parallel to the auth bypass). Frees a module's bundle hosted on the platform domain (e.g. localhost:3001) to fetch the module's /platform/* and /me endpoints cross-origin with credentials. Tunnel/prod leaves CORS off — bundles in those modes proxy through the platform at same-origin. - Bundled here for review-velocity. Conceptually a separate concern; reviewers can ask for a split if preferred. /simplify pass consensus fixes applied: - slices.SortFunc (was hand-rolled insertion sort) - DisallowUnknownFields on the validator - LIMIT on Storage.List - pgx.Identifier.Sanitize on the table name - Drop redundant inner MaxBytesReader (outer middleware caps) - Trim verbose doc comments - Single RLock in Registry.List (no TOCTOU between Keys + read) - Registry.Len() instead of len(Keys()) when only count is needed Build + go test ./... clean. No new test files yet — usage tests come with the oauth-core consumer PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/pool_cache.go | 2 +- internal/contributions/handlers.go | 148 ++++++++++++++++++++++++++++ internal/contributions/storage.go | 132 +++++++++++++++++++++++++ internal/contributions/types.go | 151 +++++++++++++++++++++++++++++ internal/core/module.go | 145 +++++++++++++++++++++++++-- internal/runtime/lambda_test.go | 6 +- mirrorstack.go | 40 ++++++++ system/manifest.go | 47 ++++++--- system/manifest_test.go | 36 +++---- system/mcp_test.go | 4 +- 10 files changed, 662 insertions(+), 49 deletions(-) create mode 100644 internal/contributions/handlers.go create mode 100644 internal/contributions/storage.go create mode 100644 internal/contributions/types.go diff --git a/db/pool_cache.go b/db/pool_cache.go index 60ccf1b..d4813d5 100644 --- a/db/pool_cache.go +++ b/db/pool_cache.go @@ -103,7 +103,7 @@ func createPool(ctx context.Context, cred Credential) (*pgxpool.Pool, error) { // might skip resetScope (caller panic, missing defer, future bug). If the // reset fails, the connection is destroyed instead of being reused. // -// set_config(_, '', false) is used instead of RESET ms.app_id because RESET +// set_config(_, ”, false) is used instead of RESET ms.app_id because RESET // errors out if the custom GUC was never set on this connection (fresh conn // returning to the pool for the first time). func afterReleaseReset(conn *pgx.Conn) bool { diff --git a/internal/contributions/handlers.go b/internal/contributions/handlers.go new file mode 100644 index 0000000..ce6ac06 --- /dev/null +++ b/internal/contributions/handlers.go @@ -0,0 +1,148 @@ +package contributions + +import ( + "errors" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + + "github.com/mirrorstack-ai/app-module-sdk/db" + "github.com/mirrorstack-ai/app-module-sdk/internal/httputil" +) + +// MaxPayloadBytes caps the body size for register requests. Slot +// payloads are config metadata (icon, label, login_path) — small. A +// 16 KB cap keeps a misbehaving contributor from blowing up the +// host's DB row size while leaving headroom for moderately rich +// shapes (e.g. arrays of column definitions for users-table-columns). +const MaxPayloadBytes = 16 * 1024 + +// DBFunc supplies a database connection scoped to the request. The +// SDK already exposes ms.DB; the handlers take it as an interface so +// tests can inject a fake. +type DBFunc func(r *http.Request) (db.Querier, func(), error) + +// Handlers wires the registry + storage + DB accessor into chi +// handlers the SDK can mount under /__mirrorstack/contrib. +type Handlers struct { + registry *Registry + storage *Storage + openDB DBFunc +} + +// NewHandlers constructs a handler set. +func NewHandlers(registry *Registry, storage *Storage, openDB DBFunc) *Handlers { + return &Handlers{registry: registry, storage: storage, openDB: openDB} +} + +// Routes returns a chi router scoped under /__mirrorstack/contrib. +// Caller is responsible for applying auth middleware (Internal scope +// is the expected wrapper — contributions move trusted module-to- +// module so the secret gate guards the write path). +func (h *Handlers) Routes() chi.Router { + r := chi.NewRouter() + r.Post("/{slot}/{id}", h.register) + r.Delete("/{slot}/{id}", h.unregister) + r.Get("/{slot}", h.list) + return r +} + +// register handles POST /{slot}/{id}. Body is the typed payload. +// Validated against the slot's declared T (unmarshal probe) before +// hitting storage. +func (h *Handlers) register(w http.ResponseWriter, r *http.Request) { + slotKey := chi.URLParam(r, "slot") + id := chi.URLParam(r, "id") + + slot, ok := h.registry.Get(slotKey) + if !ok { + httputil.JSON(w, http.StatusNotFound, httputil.ErrorResponse{Error: "unknown contribution slot"}) + return + } + if id == "" { + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "contribution id is required"}) + return + } + + // Outer httputil.MaxBytes middleware (mounted by core.Module on + // the /__mirrorstack/contrib subtree) already caps the body — + // just read it. + body, err := io.ReadAll(r.Body) + if err != nil { + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "payload unreadable"}) + return + } + if err := slot.Validate(body); err != nil { + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid payload: " + err.Error()}) + return + } + + q, release, err := h.openDB(r) + if err != nil { + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: err.Error()}) + return + } + defer release() + + if err := h.storage.Upsert(r.Context(), q, slotKey, id, body); err != nil { + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: err.Error()}) + return + } + httputil.JSON(w, http.StatusOK, map[string]string{"id": id, "slot": slotKey}) +} + +// unregister handles DELETE /{slot}/{id}. Returns 204 on success, +// 404 if the contribution wasn't registered. +func (h *Handlers) unregister(w http.ResponseWriter, r *http.Request) { + slotKey := chi.URLParam(r, "slot") + id := chi.URLParam(r, "id") + + if _, ok := h.registry.Get(slotKey); !ok { + httputil.JSON(w, http.StatusNotFound, httputil.ErrorResponse{Error: "unknown contribution slot"}) + return + } + + q, release, err := h.openDB(r) + if err != nil { + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: err.Error()}) + return + } + defer release() + + if err := h.storage.Delete(r.Context(), q, slotKey, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + httputil.JSON(w, http.StatusNotFound, httputil.ErrorResponse{Error: "contribution not found"}) + return + } + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// list handles GET /{slot}. Returns the registered contributions +// sorted newest-first. +func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { + slotKey := chi.URLParam(r, "slot") + + if _, ok := h.registry.Get(slotKey); !ok { + httputil.JSON(w, http.StatusNotFound, httputil.ErrorResponse{Error: "unknown contribution slot"}) + return + } + + q, release, err := h.openDB(r) + if err != nil { + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: err.Error()}) + return + } + defer release() + + out, err := h.storage.List(r.Context(), q, slotKey) + if err != nil { + httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: err.Error()}) + return + } + httputil.JSON(w, http.StatusOK, map[string]any{"contributions": out}) +} diff --git a/internal/contributions/storage.go b/internal/contributions/storage.go new file mode 100644 index 0000000..a090939 --- /dev/null +++ b/internal/contributions/storage.go @@ -0,0 +1,132 @@ +package contributions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/mirrorstack-ai/app-module-sdk/db" +) + +// listLimit caps the row count returned by Storage.List so a slot +// with thousands of contributions (unlikely today) can't return a +// pathologically large response. Picked to comfortably cover every +// realistic slot count in v1. +const listLimit = 1000 + +// Storage wraps the contributions table CRUD. Sanitized name is +// computed once at construction so the SQL builders don't pay the +// identifier-escape cost per call. +type Storage struct { + table string // already pgx-Sanitize'd, safe to interpolate +} + +// NewStorage constructs a Storage rooted at the given module ID. +// modulePrefix is validated upstream against moduleIDPattern in +// core.New(), so Sanitize is belt-and-suspenders. +func NewStorage(modulePrefix string) *Storage { + return &Storage{table: pgx.Identifier{modulePrefix + "_contributions"}.Sanitize()} +} + +// TableName returns the SQL-safe (already sanitized + quoted) +// contributions table name. Exposed for test diagnostics. +func (s *Storage) TableName() string { return s.table } + +// EnsureTable runs CREATE TABLE IF NOT EXISTS. Dev fallback; prod +// schema management lives in the lifecycle install hook. +func (s *Storage) EnsureTable(ctx context.Context, q db.Querier) error { + // Index name strips the surrounding quotes from the sanitized + // table name so it stays a bare identifier (Postgres errors on + // quoted index names that contain double-quotes). + idx := s.tableUnquoted() + "_slot_registered_idx" + stmt := fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %[1]s ( + slot text NOT NULL, + contribution_id text NOT NULL, + payload jsonb NOT NULL, + registered_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (slot, contribution_id) + ); + + CREATE INDEX IF NOT EXISTS %[2]s + ON %[1]s (slot, registered_at DESC);`, + s.table, idx, + ) + _, err := q.Exec(ctx, stmt) + return err +} + +// tableUnquoted returns the table name without the pgx-Sanitize +// surrounding double quotes — needed when building an index name +// (which must itself be an identifier, not a quoted-string). +func (s *Storage) tableUnquoted() string { + t := s.table + if len(t) >= 2 && t[0] == '"' && t[len(t)-1] == '"' { + return t[1 : len(t)-1] + } + return t +} + +// Upsert writes a contribution. Idempotent on (slot, contribution_id). +func (s *Storage) Upsert(ctx context.Context, q db.Querier, slot, id string, payload json.RawMessage) error { + if id == "" { + return ErrEmptyID + } + if !json.Valid(payload) { + return errors.Join(ErrInvalidPayload, errors.New("storage: payload is not valid JSON")) + } + stmt := fmt.Sprintf(` + INSERT INTO %s (slot, contribution_id, payload, registered_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (slot, contribution_id) + DO UPDATE SET payload = EXCLUDED.payload, registered_at = now()`, + s.table, + ) + _, err := q.Exec(ctx, stmt, slot, id, payload) + return err +} + +// Delete removes a contribution. Returns pgx.ErrNoRows when the row +// didn't exist; the HTTP layer maps that to 404 (double-delete is +// not silently idempotent here). +func (s *Storage) Delete(ctx context.Context, q db.Querier, slot, id string) error { + stmt := fmt.Sprintf(`DELETE FROM %s WHERE slot = $1 AND contribution_id = $2`, s.table) + tag, err := q.Exec(ctx, stmt, slot, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +// List returns up to listLimit contributions registered against +// `slot`, newest first. +func (s *Storage) List(ctx context.Context, q db.Querier, slot string) ([]Contribution, error) { + stmt := fmt.Sprintf(` + SELECT contribution_id, payload, registered_at + FROM %s + WHERE slot = $1 + ORDER BY registered_at DESC + LIMIT %d`, + s.table, listLimit, + ) + rows, err := q.Query(ctx, stmt, slot) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Contribution{} + for rows.Next() { + var c Contribution + if err := rows.Scan(&c.ID, &c.Payload, &c.RegisteredAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} diff --git a/internal/contributions/types.go b/internal/contributions/types.go new file mode 100644 index 0000000..54d691b --- /dev/null +++ b/internal/contributions/types.go @@ -0,0 +1,151 @@ +// Package contributions implements the SDK's host-side contribution +// slots: a host module declares an extension point with +// ms.DefineContribute, the SDK auto-mounts HTTP endpoints for +// register/unregister/list, and other modules register payloads +// against the slot via the Contribute call (or direct HTTP). +// +// Storage shape (per host module): +// +// _contributions ( +// slot text NOT NULL, +// contribution_id text NOT NULL, +// payload jsonb NOT NULL, +// registered_at timestamptz NOT NULL DEFAULT now(), +// PRIMARY KEY (slot, contribution_id) +// ) +// +// The host names the table prefix once (Config.ID) and the SDK creates +// the table on Start if any slot has been declared. Production schema +// management is handled by the lifecycle install hook in a follow-up. +package contributions + +import ( + "encoding/json" + "errors" + "slices" + "sync" + "time" +) + +var ( + ErrSlotNotDefined = errors.New("contributions: slot not defined") + ErrInvalidPayload = errors.New("contributions: invalid payload") + ErrEmptyKey = errors.New("contributions: slot key is empty") + ErrEmptyID = errors.New("contributions: contribution id is empty") +) + +// Slot is one declared contribution point. The validator closure is +// produced by DefineContribute generic so the SDK can typecheck +// incoming payloads against the host's declared T at register time — +// without the storage layer needing to know what T is. +type Slot struct { + Key string + validate func(data json.RawMessage) error + schemaTag string +} + +// Validate runs the per-slot JSON validator against an incoming +// payload. Wraps ErrInvalidPayload so handlers can sniff for a 400 +// without leaking internal type info to the caller. +func (s Slot) Validate(payload json.RawMessage) error { + if s.validate == nil { + return nil + } + if err := s.validate(payload); err != nil { + return errors.Join(ErrInvalidPayload, err) + } + return nil +} + +// SchemaTag returns the Go-type-name hint surfaced on the manifest. +func (s Slot) SchemaTag() string { return s.schemaTag } + +// Contribution is a stored row, returned by the list endpoint. +type Contribution struct { + ID string `json:"id"` + Payload json.RawMessage `json:"payload"` + RegisteredAt time.Time `json:"registered_at"` +} + +// Registry holds all declared slots for one module. Populated at +// DefineContribute time; consulted by the HTTP handlers + auto-mount +// path to figure out which routes to expose. +type Registry struct { + mu sync.RWMutex + slots map[string]Slot +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { + return &Registry{slots: make(map[string]Slot)} +} + +// Define stores the slot. Re-defining the same key panics — slot +// declarations are static at module init. +func (r *Registry) Define(s Slot) error { + if s.Key == "" { + return ErrEmptyKey + } + r.mu.Lock() + defer r.mu.Unlock() + if _, dup := r.slots[s.Key]; dup { + return errors.New("contributions: slot already defined: " + s.Key) + } + r.slots[s.Key] = s + return nil +} + +// Get looks up a slot by key. Returns (Slot{}, false) if undefined. +func (r *Registry) Get(key string) (Slot, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + s, ok := r.slots[key] + return s, ok +} + +// Len returns the number of declared slots. Lets callers decide +// "is this module accepting any contributions?" without paying the +// allocation cost of List() — used in core.Module.Start to skip the +// EnsureTable round-trip when no slots are declared. +func (r *Registry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.slots) +} + +// SlotInfo is the manifest-shaped projection of a Slot — no closure, +// just metadata the manifest serializes. +type SlotInfo struct { + Key string `json:"key"` + SchemaTag string `json:"schemaTag,omitempty"` +} + +// List returns every declared slot's manifest projection, sorted by +// key for stable output. Single RLock for the whole walk so no +// concurrent Define can change the map between key collection and +// row read. +func (r *Registry) List() []SlotInfo { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]SlotInfo, 0, len(r.slots)) + for _, s := range r.slots { + out = append(out, SlotInfo{Key: s.Key, SchemaTag: s.schemaTag}) + } + slices.SortFunc(out, func(a, b SlotInfo) int { + if a.Key < b.Key { + return -1 + } + if a.Key > b.Key { + return 1 + } + return 0 + }) + return out +} + +// NewSlot constructs a Slot with a validator closure. Generic +// DefineContribute[T] in the parent package builds the closure +// where T is in scope. +func NewSlot(key string, schemaTag string, validate func(json.RawMessage) error) Slot { + return Slot{Key: key, schemaTag: schemaTag, validate: validate} +} diff --git a/internal/core/module.go b/internal/core/module.go index 82ff0ff..b9b2397 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -4,7 +4,9 @@ package core import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -24,6 +26,7 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/auth" "github.com/mirrorstack-ai/app-module-sdk/cache" "github.com/mirrorstack-ai/app-module-sdk/db" + "github.com/mirrorstack-ai/app-module-sdk/internal/contributions" "github.com/mirrorstack-ai/app-module-sdk/internal/httputil" "github.com/mirrorstack-ai/app-module-sdk/internal/migration" "github.com/mirrorstack-ai/app-module-sdk/internal/registry" @@ -86,6 +89,8 @@ type Module struct { router *chi.Mux logger *log.Logger registry *registry.Registry + contribReg *contributions.Registry // declared contribution slots + contribStorage *contributions.Storage // contributions table CRUD internalAuth func(http.Handler) http.Handler poolCache *db.PoolCache // production: per-app DB pools devDBOnce sync.Once // dev mode: lazy DB init @@ -129,15 +134,32 @@ func New(cfg Config) (*Module, error) { return nil, fmt.Errorf("mirrorstack: Config.Slug %q must match %s (lowercase, starts with letter, hyphens allowed, max 16 chars)", cfg.Slug, moduleSlugPattern) } m := &Module{ - config: cfg, - router: chi.NewRouter(), - logger: log.New(os.Stderr, "mirrorstack: ", log.LstdFlags), - registry: registry.New(), - internalAuth: auth.InternalAuth(), - poolCache: db.NewPoolCache(), - cacheCache: cache.NewClientCache(), - taskHandlers: make(map[string]taskEntry), - signingKey: []byte(os.Getenv("MS_TASK_SIGNING_KEY")), + config: cfg, + router: chi.NewRouter(), + logger: log.New(os.Stderr, "mirrorstack: ", log.LstdFlags), + registry: registry.New(), + contribReg: contributions.NewRegistry(), + contribStorage: contributions.NewStorage(cfg.ID), + internalAuth: auth.InternalAuth(), + poolCache: db.NewPoolCache(), + cacheCache: cache.NewClientCache(), + taskHandlers: make(map[string]taskEntry), + signingKey: []byte(os.Getenv("MS_TASK_SIGNING_KEY")), + } + + // Local-dev CORS: when MS_INTERNAL_SECRET is unset (i.e. the + // auth/PlatformAuth + InternalAuth bypass branch is active), echo + // the request Origin so a module's React bundle running on the + // platform's domain (e.g. localhost:3001) can fetch the module's + // own /platform/* and /me endpoints cross-origin. Wildcard "*" + // won't do — the bundle's api.ts uses credentials: 'include' for + // the /me cookie flow, and browsers reject "*" with credentials. + // + // Tunnel/prod (secret set) leaves CORS off entirely — bundles in + // those modes go through the platform proxy at same-origin, so + // cross-origin requests from random origins shouldn't be allowed. + if os.Getenv("MS_INTERNAL_SECRET") == "" { + m.router.Use(localDevCORS) } // Eagerly initialize SQS client when queue URL is configured. @@ -299,6 +321,23 @@ func (m *Module) Start() error { } } + // Auto-create the contributions table when any slot is declared. + // CREATE TABLE IF NOT EXISTS is safe to call on every Start; the + // guard keeps modules that never use DefineContribute from + // touching their DB for an empty feature. Production schema + // management lands via the lifecycle install hook in a follow-up. + if m.contribReg.Len() > 0 { + q, release, err := m.DB(context.Background()) + if err != nil { + return fmt.Errorf("mirrorstack: contributions: open DB: %w", err) + } + if err := m.contribStorage.EnsureTable(context.Background(), q); err != nil { + release() + return fmt.Errorf("mirrorstack: contributions: ensure table: %w", err) + } + release() + } + port := os.Getenv("PORT") if port == "" { port = "8080" @@ -443,12 +482,30 @@ func (m *Module) mountSystemRoutes() { r.Head("/web/*", system.WebHandler(m.config.WebDir)) r.Options("/web/*", system.WebHandler(m.config.WebDir)) + // Contribution slots — host modules declare with + // ms.DefineContribute and the SDK auto-mounts register / + // unregister / list endpoints here. Internal scope because + // writes move trusted module-to-module; the read path is + // inside the same group for symmetry — host modules that want + // a Platform-scoped read with permission gating add their own + // wrapper that calls into storage directly. + r.Route("/contrib", func(r chi.Router) { + r.Use(httputil.MaxBytes(contributions.MaxPayloadBytes + 1024)) + r.Use(m.internalAuth) + handlers := contributions.NewHandlers( + m.contribReg, + m.contribStorage, + func(req *http.Request) (db.Querier, func(), error) { return m.DB(req.Context()) }, + ) + r.Mount("/", handlers.Routes()) + }) + r.Route("/platform", func(r chi.Router) { r.Use(httputil.MaxBytes(64 * 1024)) // 64 KB — lifecycle bodies are tiny r.Use(m.internalAuth) r.Get("/manifest", system.ManifestHandler( m.config.ID, m.config.Slug, m.config.Name, m.config.Icon, - m.config.SQL, m.config.Versions, m.registry, + m.config.SQL, m.config.Versions, m.registry, m.contribReg, )) r.Route("/lifecycle", func(r chi.Router) { // App and module migrations are separate tracks on disjoint @@ -524,3 +581,71 @@ func Internal(fn func(r chi.Router)) { mustDefault("Internal").Internal(fn) } // DefaultModule returns the default module for advanced use cases. func DefaultModule() *Module { return defaultModule } + +// DefineContributeSlot is the non-generic core. The exported +// generic wrapper lives in mirrorstack.go where T is in scope — +// methods can't be generic in Go, so the type-aware closure is +// built once at the call site and the Module just stores the +// resulting Slot. +func (m *Module) DefineContributeSlot(slot contributions.Slot) { + if err := m.contribReg.Define(slot); err != nil { + panic("mirrorstack: DefineContribute: " + err.Error()) + } +} + +// NewContributionSlot builds a Slot whose validator unmarshals into +// T with DisallowUnknownFields so contributors can't sneak in extra +// fields the host doesn't expect (silent jsonb pollution). Schema +// tag is the Go type name, surfaced on the manifest. +func NewContributionSlot[T any](key string) contributions.Slot { + var zero T + schemaTag := fmt.Sprintf("%T", zero) + return contributions.NewSlot(key, schemaTag, func(data json.RawMessage) error { + var v T + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + return dec.Decode(&v) + }) +} + +// DefineContribute is the top-level entry point modules call from +// main.go: +// +// ms.DefineContribute[ProviderContribution]("providers") +// +// Builds the type-aware Slot and stores it on the default module. +// Panics on duplicate key or before ms.Init — matches the existing +// RegisterUI / RequirePermission startup-error conventions. +func DefineContribute[T any](key string) { + mustDefault("DefineContribute").DefineContributeSlot(NewContributionSlot[T](key)) +} + +// Contributions returns every contribution slot the default module +// has declared. Surfaced for the manifest builder + tests. +func Contributions() []contributions.SlotInfo { + if defaultModule == nil { + return nil + } + return defaultModule.contribReg.List() +} + +// localDevCORS echoes the request Origin (with credentials) and answers +// OPTIONS preflights. Installed on the module router only when +// MS_INTERNAL_SECRET is unset — gated parallel to the auth bypasses. +func localDevCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Add("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-MS-Internal-Secret") + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/runtime/lambda_test.go b/internal/runtime/lambda_test.go index fd1fd55..15430cc 100644 --- a/internal/runtime/lambda_test.go +++ b/internal/runtime/lambda_test.go @@ -154,9 +154,9 @@ func TestNewLambdaHandler_StripXMSHeaders(t *testing.T) { Method: "GET", Path: "/check", Headers: map[string]string{ - "X-MS-App-Role": "admin", // spoofed — should be stripped - "x-ms-user-id": "fake-user", // case-insensitive — should be stripped - "Content-Type": "text/plain", // legit — should pass through + "X-MS-App-Role": "admin", // spoofed — should be stripped + "x-ms-user-id": "fake-user", // case-insensitive — should be stripped + "Content-Type": "text/plain", // legit — should pass through }, }) diff --git a/mirrorstack.go b/mirrorstack.go index d8ec563..8ceb523 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -15,6 +15,7 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/cache" "github.com/mirrorstack-ai/app-module-sdk/db" + "github.com/mirrorstack-ai/app-module-sdk/internal/contributions" "github.com/mirrorstack-ai/app-module-sdk/internal/core" "github.com/mirrorstack-ai/app-module-sdk/meter" "github.com/mirrorstack-ai/app-module-sdk/roles" @@ -191,3 +192,42 @@ func OnTask(name string, handler TaskHandler, opts ...TaskOption) { func RunTask(ctx context.Context, name string, payload json.RawMessage) (string, error) { return core.RunTask(ctx, name, payload) } + +// --- Contribution slots --- + +// ContributionSlot is the manifest projection of a declared slot. +type ContributionSlot = contributions.SlotInfo + +// DefineContribute declares a contribution slot on the default +// module. The type parameter T fixes the payload shape — incoming +// register requests must unmarshal cleanly into T. The SDK +// auto-mounts: +// +// POST /__mirrorstack/contrib// register/upsert +// DELETE /__mirrorstack/contrib// unregister +// GET /__mirrorstack/contrib/ list all registered +// +// Each is Internal-scoped (HMAC-gated by the SDK). Host modules that +// want to expose a Platform-scoped read with permission gating wrap +// these endpoints in their own handler — see oauth-core's +// /platform/providers for the pattern. +// +// Panics on duplicate key or before Init — matches RegisterUI / +// RequirePermission startup-error conventions. +// +// type ProviderContribution struct { +// Name string `json:"name"` +// Icon string `json:"icon"` +// LoginPath string `json:"login_path"` +// CallbackPath string `json:"callback_path"` +// } +// +// ms.DefineContribute[ProviderContribution]("providers") +func DefineContribute[T any](key string) { + core.DefineContribute[T](key) +} + +// Contributions returns every contribution slot declared on the +// default module. Useful for tests and for hosts that want to expose +// a /platform/* read endpoint listing what they accept. +func Contributions() []ContributionSlot { return core.Contributions() } diff --git a/system/manifest.go b/system/manifest.go index 9d935e9..529a782 100644 --- a/system/manifest.go +++ b/system/manifest.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + "github.com/mirrorstack-ai/app-module-sdk/internal/contributions" "github.com/mirrorstack-ai/app-module-sdk/internal/httputil" "github.com/mirrorstack-ai/app-module-sdk/internal/migration" "github.com/mirrorstack-ai/app-module-sdk/internal/registry" @@ -34,6 +35,11 @@ type ManifestPayload struct { // UI is the module's declared UI surface (RegisterUI). Nil/absent when // the module ships no UI — callers must nil-check before reading. UI *registry.ModuleUI `json:"ui,omitempty"` + // DefinedContributions lists the contribution slots this module + // accepts (ms.DefineContribute). The catalog reads this to know + // what other modules can plug into. Always present; empty array + // when no slots are declared. + DefinedContributions []contributions.SlotInfo `json:"definedContributions"` } // ManifestMCP declares the MCP tool and resource surface of the module. The @@ -90,7 +96,10 @@ func buildManifestMCP(reg *registry.Registry) ManifestMCP { // instead of `"versions":null` — the handler owns the output contract and // normalizes here the same way Registry normalizes Routes/Emits/Subscribes/ // Schedules at their getters. -func ManifestHandler(id, slug, name, icon string, sqlFS fs.FS, versions map[string]MigrationVersions, reg *registry.Registry) http.HandlerFunc { +// +// contribReg is the module's contribution-slot registry. Pass nil to omit +// declared contributions from the manifest entirely (e.g. tests). +func ManifestHandler(id, slug, name, icon string, sqlFS fs.FS, versions map[string]MigrationVersions, reg *registry.Registry, contribReg *contributions.Registry) http.HandlerFunc { if versions == nil { versions = map[string]MigrationVersions{} } @@ -110,21 +119,29 @@ func ManifestHandler(id, slug, name, icon string, sqlFS fs.FS, versions map[stri log.Printf("mirrorstack: manifest module migration version unavailable (check Config.SQL is set correctly)") } + var contribSlots []contributions.SlotInfo + if contribReg != nil { + contribSlots = contribReg.List() + } else { + contribSlots = []contributions.SlotInfo{} + } + httputil.JSON(w, http.StatusOK, ManifestPayload{ - ID: id, - Slug: slug, - Defaults: ManifestDefaults{Name: name, Icon: icon}, - Description: reg.Description(), - Dependencies: reg.Dependencies(), - Migration: MigrationVersions{App: appVersion, Module: moduleVersion}, - Versions: versions, - Routes: reg.Routes(), - Events: ManifestEvents{Emits: reg.Emits(), Subscribes: reg.Subscribes()}, - Schedules: reg.Schedules(), - Tasks: reg.Tasks(), - Permissions: reg.Permissions(), - MCP: buildManifestMCP(reg), - UI: reg.UI(), + ID: id, + Slug: slug, + Defaults: ManifestDefaults{Name: name, Icon: icon}, + Description: reg.Description(), + Dependencies: reg.Dependencies(), + Migration: MigrationVersions{App: appVersion, Module: moduleVersion}, + Versions: versions, + Routes: reg.Routes(), + Events: ManifestEvents{Emits: reg.Emits(), Subscribes: reg.Subscribes()}, + Schedules: reg.Schedules(), + Tasks: reg.Tasks(), + Permissions: reg.Permissions(), + MCP: buildManifestMCP(reg), + UI: reg.UI(), + DefinedContributions: contribSlots, }) } } diff --git a/system/manifest_test.go b/system/manifest_test.go index 8912de2..878192f 100644 --- a/system/manifest_test.go +++ b/system/manifest_test.go @@ -29,7 +29,7 @@ func decodeManifest(t *testing.T, h http.HandlerFunc) ManifestPayload { func TestManifest_IDAndDefaults(t *testing.T) { t.Parallel() - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New())) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New(), nil)) if got.ID != "media" { t.Errorf("id = %q, want media", got.ID) @@ -44,7 +44,7 @@ func TestManifest_IDAndDefaults(t *testing.T) { func TestManifest_SlugSurfaced(t *testing.T) { t.Parallel() - got := decodeManifest(t, ManifestHandler("m15c0f543cf164433b524d312dbf68159", "oauth", "Google Sign-In", "vpn_key", nil, nil, registry.New())) + got := decodeManifest(t, ManifestHandler("m15c0f543cf164433b524d312dbf68159", "oauth", "Google Sign-In", "vpn_key", nil, nil, registry.New(), nil)) if got.Slug != "oauth" { t.Errorf("slug = %q, want oauth", got.Slug) } @@ -54,7 +54,7 @@ func TestManifest_SlugOmittedWhenEmpty(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New()).ServeHTTP(rec, req) + ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New(), nil).ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), `"slug"`) { t.Errorf("expected \"slug\" key to be omitted when empty, got: %s", rec.Body.String()) } @@ -69,7 +69,7 @@ func TestManifest_RoutesFromAllScopes(t *testing.T) { reg.AddRoute(registry.ScopePublic, "GET", "/items") reg.AddRoute(registry.ScopeInternal, "POST", "/events/on-user-deleted") - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, reg)) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, reg, nil)) if len(got.Routes[registry.ScopePlatform]) != 2 { t.Errorf("platform routes = %d, want 2", len(got.Routes[registry.ScopePlatform])) @@ -85,7 +85,7 @@ func TestManifest_RoutesFromAllScopes(t *testing.T) { func TestManifest_EmptyScopesPresent(t *testing.T) { t.Parallel() - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New())) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New(), nil)) for _, scope := range registry.AllScopes() { s, ok := got.Routes[scope] @@ -106,7 +106,7 @@ func TestManifest_EventsAndSchedules(t *testing.T) { reg.AddSubscribe("oauth.user_deleted", "/internal/events/on-user-deleted") reg.AddSchedule("cleanup-temp", "0 3 * * *", "/crons/cleanup-temp") - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, reg)) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, reg, nil)) if len(got.Events.Emits) != 1 || got.Events.Emits[0] != "created" { t.Errorf("events.emits = %v, want [created]", got.Events.Emits) @@ -125,7 +125,7 @@ func TestManifest_EmptyEventsAndSchedules_NotNull(t *testing.T) { // Verify the JSON has [] / {} not null when nothing is declared. req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New()).ServeHTTP(rec, req) + ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New(), nil).ServeHTTP(rec, req) body := rec.Body.String() // Note: "module" is omitempty on MigrationVersions and VersionEntry, so @@ -167,7 +167,7 @@ func TestManifest_MigrationVersions(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", tc.fsys, nil, registry.New())) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", tc.fsys, nil, registry.New(), nil)) if got.Migration != tc.want { t.Errorf("migration = %+v, want %+v", got.Migration, tc.want) } @@ -178,7 +178,7 @@ func TestManifest_MigrationVersions(t *testing.T) { func TestManifest_NilSQL_EmptyMigration(t *testing.T) { t.Parallel() - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New())) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, registry.New(), nil)) if got.Migration.App != "" || got.Migration.Module != "" { t.Errorf("migration = %+v, want both empty when SQL fs is nil", got.Migration) } @@ -197,7 +197,7 @@ func TestManifest_Versions(t *testing.T) { "v0.1.0": {App: "0008", Module: "0002"}, "v0.2.0": {App: "0012"}, } - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, versions, registry.New())) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, versions, registry.New(), nil)) if len(got.Versions) != 2 { t.Fatalf("versions = %v, want 2 entries", got.Versions) @@ -222,7 +222,7 @@ func TestManifest_Permissions(t *testing.T) { reg.AddPermission("media.upload", []string{"admin", "member"}) reg.AddPermission("media.view", []string{"admin"}) // duplicate name → dropped - got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, reg)) + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, reg, nil)) if len(got.Permissions) != 2 { t.Fatalf("permissions = %d, want 2: %+v", len(got.Permissions), got.Permissions) @@ -249,7 +249,7 @@ func TestManifest_DescriptionPopulated(t *testing.T) { reg := registry.New() reg.SetDescription("A demo module") - got := decodeManifest(t, ManifestHandler("demo", "", "Demo", "box", nil, nil, reg)) + got := decodeManifest(t, ManifestHandler("demo", "", "Demo", "box", nil, nil, reg, nil)) if got.Description != "A demo module" { t.Errorf("description = %q, want %q", got.Description, "A demo module") } @@ -260,7 +260,7 @@ func TestManifest_DescriptionOmittedWhenEmpty(t *testing.T) { req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New()).ServeHTTP(rec, req) + ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New(), nil).ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), `"description"`) { t.Errorf("expected \"description\" key to be omitted when empty, got: %s", rec.Body.String()) @@ -274,7 +274,7 @@ func TestManifest_DependenciesPopulated(t *testing.T) { reg.AddDependency(registry.Dependency{ID: "oauth-core"}) reg.AddDependency(registry.Dependency{ID: "video", Optional: true}) - got := decodeManifest(t, ManifestHandler("demo", "", "Demo", "box", nil, nil, reg)) + got := decodeManifest(t, ManifestHandler("demo", "", "Demo", "box", nil, nil, reg, nil)) if len(got.Dependencies) != 2 { t.Fatalf("len(dependencies) = %d, want 2", len(got.Dependencies)) } @@ -291,7 +291,7 @@ func TestManifest_EmptyDependenciesIsArrayNotNull(t *testing.T) { req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New()).ServeHTTP(rec, req) + ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New(), nil).ServeHTTP(rec, req) body := rec.Body.String() if !strings.Contains(body, `"dependencies":[]`) { @@ -307,7 +307,7 @@ func TestManifest_OptionalOmittedInJSONWhenFalse(t *testing.T) { req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("demo", "", "Demo", "box", nil, nil, reg).ServeHTTP(rec, req) + ManifestHandler("demo", "", "Demo", "box", nil, nil, reg, nil).ServeHTTP(rec, req) body := rec.Body.String() // Required dep should not carry "optional":false in wire shape. @@ -332,7 +332,7 @@ func TestManifest_UIPopulatedWhenRegistered(t *testing.T) { }, }) - got := decodeManifest(t, ManifestHandler("demo", "", "Demo", "box", nil, nil, reg)) + got := decodeManifest(t, ManifestHandler("demo", "", "Demo", "box", nil, nil, reg, nil)) if got.UI == nil { t.Fatal("manifest.ui is nil; expected populated UI") } @@ -349,7 +349,7 @@ func TestManifest_UIOmittedWhenNotRegistered(t *testing.T) { req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New()).ServeHTTP(rec, req) + ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New(), nil).ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), `"ui"`) { t.Errorf("expected \"ui\" key omitted when no RegisterUI was called, got: %s", rec.Body.String()) diff --git a/system/mcp_test.go b/system/mcp_test.go index 512bdf4..07a0020 100644 --- a/system/mcp_test.go +++ b/system/mcp_test.go @@ -254,7 +254,7 @@ func TestManifest_IncludesMCP(t *testing.T) { req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("demo", "", "Demo", "box", nil, nil, reg).ServeHTTP(rec, req) + ManifestHandler("demo", "", "Demo", "box", nil, nil, reg, nil).ServeHTTP(rec, req) var got ManifestPayload if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { @@ -278,7 +278,7 @@ func TestManifest_EmptyMCPIsEmptyArrays(t *testing.T) { req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) rec := httptest.NewRecorder() - ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New()).ServeHTTP(rec, req) + ManifestHandler("demo", "", "Demo", "box", nil, nil, registry.New(), nil).ServeHTTP(rec, req) body := rec.Body.String() if !strings.Contains(body, `"mcp":{"tools":[],"resources":[]}`) {