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":[]}`) {