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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions db/pool_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ func afterReleaseReset(conn *pgx.Conn) bool {
// it uses session-scoped SET / set_config(_, _, false). The Tx() function uses
// transaction-local SET LOCAL inside its BEGIN block.
func AcquireScoped(ctx context.Context, pool *pgxpool.Pool) (Querier, func(), error) {
return AcquireScopedConn(ctx, pool)
}

// AcquireScopedConn is AcquireScoped but returns the concrete *pgxpool.Conn
// instead of the narrower Querier interface. Callers that need the raw driver
// connection underneath — e.g. COPY FROM STDIN via conn.Conn().PgConn(),
// which db.Querier does not expose — use this; everything else should keep
// using AcquireScoped.
func AcquireScopedConn(ctx context.Context, pool *pgxpool.Pool) (*pgxpool.Conn, func(), error) {
conn, err := pool.Acquire(ctx)
if err != nil {
return nil, nil, fmt.Errorf("mirrorstack/db: failed to acquire connection: %w", err)
Expand Down
29 changes: 29 additions & 0 deletions internal/core/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ func (m *Module) resolvePool(ctx context.Context) (*pgxpool.Pool, func(), error)
return m.resolvePoolFor(ctx, db.CredentialFrom)
}

// seedConn resolves an app-schema-scoped connection for system.SeedHandler
// (the dev-mount seed endpoint) WITHOUT the cross-module dev guard that
// Module.DB/Tx apply. The seed endpoint legitimately COPYs into another
// module's exposure-anchored dependency table — the platform already
// enforced that exposure-anchor grant before it ever sent the chunk (see
// devseed.Seeder.dependencyTables on the platform side) — so routing it
// through devGuardFor would reject every dependency-table seed in dev mode
// with a false-positive cross-module error.
//
// Also unlike Module.DB, this returns the concrete *pgxpool.Conn (via
// db.AcquireScopedConn) rather than the narrower db.Querier: COPY FROM STDIN
// needs the raw driver connection's PgConn(), which db.Querier does not
// expose.
func (m *Module) seedConn(ctx context.Context) (*pgxpool.Conn, func(), error) {
pool, releasePool, err := m.resolvePool(ctx)
if err != nil {
return nil, nil, err
}
conn, releaseConn, err := db.AcquireScopedConn(ctx, pool)
if err != nil {
releasePool()
return nil, nil, err
}
return conn, func() {
releaseConn()
releasePool()
}, nil
}

// resolvePoolFor is the shared implementation behind resolvePool and
// resolveModulePool. Production reads a credential from the context via
// getCred (different context key per scope) and pulls a refcount-pinned
Expand Down
65 changes: 48 additions & 17 deletions internal/core/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,14 @@ type Module struct {
devProvision sync.Map
}

// devAppSchemaName derives the per-app Postgres schema from an app id, matching
// the platform's ids.AppSchemaName convention (app_<uuid-with-underscores>).
// It validates against the same pattern the production Lambda shim uses
// (runtime.AppSchemaPattern) so dev and prod agree on the shape;
// pgx.Identifier.Sanitize is the second line of defense before SQL. Returns
// ok=false if the result is not a valid schema identifier.
// devAppSchemaName derives the per-app Postgres schema from an app id,
// delegating to runtime.AppSchemaName so this package and system.SeedHandler
// (which derives the same schema from the dev-mount seed contract's bare
// appId) can never drift on the shape. pgx.Identifier.Sanitize is the second
// line of defense before SQL. Returns ok=false if the result is not a valid
// schema identifier.
func devAppSchemaName(appID string) (string, bool) {
schema := "app_" + strings.ReplaceAll(strings.ToLower(appID), "-", "_")
if !runtime.AppSchemaPattern.MatchString(schema) {
return "", false
}
return schema, true
return runtime.AppSchemaName(appID)
}

// devAppSchemaMiddleware is the dev analog of the Lambda shim's per-request
Expand Down Expand Up @@ -793,9 +789,25 @@ func (m *Module) mountSystemRoutes() {
})

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(
// 8 MB group-wide backstop. Defense-in-depth only: every route
// below applies its own tighter cap (64 KB via smallBody, or
// seed's own 2 MB reader — see system/lifecycle.go's
// seedMaxBodyBytes), and nesting http.MaxBytesReader wraps only
// ever shrinks the effective limit to the smallest one in the
// chain, so this never fights those. It exists so a FUTURE route
// added here without remembering its own cap still gets bounded
// instead of silently inheriting an unlimited body.
r.Use(httputil.MaxBytes(8 << 20))
// 64 KB — manifest + lifecycle install/upgrade/downgrade/uninstall
// bodies are tiny. Applied per-route via .With() rather than
// folded into the group backstop above so the seed route below
// (COPY-text chunks up to 2MB) can enforce its own larger cap
// instead of inheriting this smaller one — nesting two
// http.MaxBytesReader wraps only ever shrinks the effective limit
// to the smaller of the two.
smallBody := httputil.MaxBytes(64 * 1024)
r.With(smallBody).Get("/manifest", system.ManifestHandler(
m.config.ID, m.config.Slug, m.config.Name, m.config.Icon, m.config.Tags,
m.config.SQL, m.config.Versions, m.registry, m.contribReg,
))
Expand All @@ -807,10 +819,29 @@ func (m *Module) mountSystemRoutes() {
for _, scope := range migration.AllScopes() {
runTx := m.lifecycleTxRunner(scope)
r.Route("/"+string(scope), func(r chi.Router) {
r.Post("/install", system.InstallHandler(m.config.SQL, scope, runTx))
r.Post("/upgrade", system.UpgradeHandler(m.config.SQL, scope, runTx))
r.Post("/downgrade", system.DowngradeHandler(m.config.SQL, scope, runTx))
r.Post("/uninstall", system.UninstallHandler()) // no scope — no-op for both
r.With(smallBody).Post("/install", system.InstallHandler(m.config.SQL, scope, runTx))
r.With(smallBody).Post("/upgrade", system.UpgradeHandler(m.config.SQL, scope, runTx))
r.With(smallBody).Post("/downgrade", system.DowngradeHandler(m.config.SQL, scope, runTx))
r.With(smallBody).Post("/uninstall", system.UninstallHandler()) // no scope — no-op for both

if scope == migration.ScopeApp && !runtime.IsLambda() {
// Dev-mount seed (F2 of the transient-overlay
// work): api-platform's devseed.Seeder POSTs one
// Postgres COPY-text chunk per call into this
// app's schema. App-scope only — there is no
// module-scope seed. SeedHandler enforces its own
// 2MB http.MaxBytesReader (system/lifecycle.go),
// so it deliberately does NOT get smallBody.
//
// !runtime.IsLambda() mirrors the lambda-invoke shim
// gate above (mountSystemRoutes, ~line 760): SeedHandler
// executes req.CreateSQL as raw request-body SQL, a
// surface the rest of the lifecycle deliberately
// withholds in prod. Dev/tunnel only.
r.Post("/seed", system.SeedHandler(func(ctx context.Context) (system.SeedConn, func(), error) {
return m.seedConn(ctx)
}))
}
})
}
})
Expand Down
53 changes: 53 additions & 0 deletions internal/core/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,59 @@ func TestLifecycle_ScopeTxRunnerWiring(t *testing.T) {
}
}

// TestSeed_RouteRequiresInternalSecret covers the dev-mount seed route's two
// gates: (1) it inherits internalAuth like every other /platform route, and
// (2) it is mounted only outside Lambda mode (mountSystemRoutes gates it with
// !runtime.IsLambda(), mirroring the lambda-invoke shim a few lines above it).
//
// The Lambda-mode branch (route absent entirely) can't be driven from this
// package the way TestLambdaInvokeShim_MountedPostOnly notes for the shim:
// runtime.IsLambda() reads an unexported package-level var evaluated once at
// process init from AWS_LAMBDA_FUNCTION_NAME, so a test in package core has
// no supported way to flip it mid-run. This test instead asserts the route is
// present (and secret-gated) in the non-Lambda/dev mount — the half the
// !runtime.IsLambda() guard in mountSystemRoutes is the reviewed line for.
func TestSeed_RouteRequiresInternalSecret(t *testing.T) {
m := newTestModuleWithSecret(t, "test")
route := "/__mirrorstack/platform/lifecycle/app/seed"

// Without secret → 401, proving the route inherits internalAuth.
rec := doRequest(t, m.Router(), "POST", route)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status without secret = %d, want 401", rec.Code)
}

// With secret, in this (non-Lambda) test process, the route must be
// mounted — i.e. not 404. The handler itself will fail past auth (no
// real DB, malformed body) but that is out of scope for this test.
rec = doRequestWithSecret(t, m.Router(), "POST", route, "secret")
if rec.Code == http.StatusNotFound {
t.Errorf("status with secret = 404, want route to be mounted outside Lambda mode")
}
}

// TestSeedRoute_MaxBytesLimit guards the /platform group's defense-in-depth
// backstop (mountSystemRoutes: r.Use(httputil.MaxBytes(8 << 20))) against
// regressing seed's own tighter cap. A body over seed's 2 MB reader
// (system/lifecycle.go's seedMaxBodyBytes) but well under the group's 8 MB
// backstop must still trip 413 from seed's own reader — proving nested
// http.MaxBytesReader wraps shrink to the smallest cap in the chain rather
// than the group backstop silently widening the effective limit to 8 MB.
func TestSeedRoute_MaxBytesLimit(t *testing.T) {
m := newTestModuleWithSecret(t, "test")

huge := strings.Repeat("a", 2*1024*1024+1024)
body := `{"appId":"seedtest","table":"items","columns":["id"],"data":"` + huge + `","first":true}`
req := httptest.NewRequest("POST", "/__mirrorstack/platform/lifecycle/app/seed", strings.NewReader(body))
req.Header.Set("X-MS-Internal-Secret", "secret")
rec := httptest.NewRecorder()
m.Router().ServeHTTP(rec, req)

if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413 for body over seed's 2MB cap, nested under the group's 8MB backstop", rec.Code)
}
}

func TestManifest_RegisteredScopesStillRouteCorrectly(t *testing.T) {
// Verify that the chi.Walk + re-register approach in scopedRoutes preserves
// the original routing behavior. Routes registered via Platform/Public/Internal
Expand Down
18 changes: 18 additions & 0 deletions internal/runtime/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ import (
// validates it identically — keeping dev and prod from drifting on the shape.
var AppSchemaPattern = regexp.MustCompile(`^app_[a-z0-9_]+$`)

// AppSchemaName derives the per-app Postgres schema from an app id, matching
// the platform's ids.AppSchemaName convention (app_<uuid-with-underscores>).
// Returns ok=false if the result does not match AppSchemaPattern (e.g. appID
// is empty or contains characters outside [a-zA-Z0-9-]).
//
// Shared by every caller that must derive app_<id> from a bare app id rather
// than receive the schema pre-resolved: the dev per-request schema middleware
// (core.devAppSchemaMiddleware, via its devAppSchemaName wrapper) and the
// dev-mount seed endpoint (system.SeedHandler), whose wire contract carries
// only appId — see api-platform's devseed.Seeder.
func AppSchemaName(appID string) (string, bool) {
schema := "app_" + strings.ReplaceAll(strings.ToLower(appID), "-", "_")
if !AppSchemaPattern.MatchString(schema) {
return "", false
}
return schema, true
}

// Resources holds per-invocation credentials for all platform services.
type Resources struct {
DB *db.Credential `json:"db,omitempty"`
Expand Down
153 changes: 153 additions & 0 deletions system/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"strconv"
"strings"

"github.com/jackc/pgx/v5"

"github.com/mirrorstack-ai/app-module-sdk/db"
"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/runtime"
)

// LifecycleResult is the JSON response for install/upgrade/downgrade. Install
Expand Down Expand Up @@ -302,3 +307,151 @@ func decodeUpgradeRequest(w http.ResponseWriter, r *http.Request) (UpgradeReques
}
return req, true
}

// seedMaxBodyBytes caps one seed POST's body. The platform's devseed.Seeder
// batches each COPY-text chunk to <=1MB (seedBatchBytes there); 2MB leaves
// headroom for the JSON envelope + column list without ever being the
// binding constraint on a well-behaved caller.
const seedMaxBodyBytes = 2 << 20

// SeedRequest is the platform->SDK wire shape for the dev-mount seed endpoint
// (devseed.Seeder on the api-platform side — a cross-repo contract; keep
// field tags byte-for-byte in sync with that package's seedRequest). One
// POST carries one Postgres COPY-text chunk for one table.
type SeedRequest struct {
AppID string `json:"appId"`
Table string `json:"table"` // physical m<uuid-hex>_* relation name
Columns []string `json:"columns"` // explicit COPY column list, ordinal order
CreateSQL string `json:"createSql,omitempty"`
Data string `json:"data"` // Postgres COPY text rows
First bool `json:"first"` // first chunk for this table
}

// SeedResponse is the SDK's per-chunk answer.
type SeedResponse struct {
Skipped bool `json:"skipped,omitempty"`
}

// SeedConn is the connection seam SeedHandler needs: the db.Querier surface
// for CreateSQL and the if-empty guard, plus the raw driver connection COPY
// FROM STDIN requires (db.Querier exposes neither PgConn() nor the
// low-level copy protocol). *pgxpool.Conn satisfies this directly — its
// Conn() method returns the *pgx.Conn that PgConn() rides on.
type SeedConn interface {
db.Querier
Conn() *pgx.Conn
}

// SeedConnAcquirer resolves a SeedConn scoped to ctx's app schema for one
// request. Implementations must NOT route this through a cross-module dev
// guard the way Module.DB/Tx do: the seed endpoint legitimately writes into
// another module's exposure-anchored dependency tables, a grant the platform
// already enforced before it ever sent the chunk.
type SeedConnAcquirer func(ctx context.Context) (SeedConn, func(), error)

// SeedHandler returns an http.HandlerFunc for the dev-mount seed endpoint:
// api-platform's devseed.Seeder POSTs one Postgres COPY-text chunk per call,
// targeting either the module's own table or an exposure-anchored producer
// dependency table (see package devseed on the platform side for the full
// design). This is the only lifecycle verb scoped to "app" alone — there is
// no module-scope seed, so the caller mounts it only under that scope's
// route (see internal/core/module.go's mountSystemRoutes).
//
// acquire resolves the connection for each request; it is responsible for
// putting ctx's app schema on search_path (via db.WithSchema + a scoped
// acquire, e.g. Module.seedConn) — SeedHandler itself only derives the
// schema NAME from RequestBody.AppID and injects it into ctx.
//
// First=true chunks run the create-then-if-empty sequence: CreateSQL (when
// non-empty) materializes a table the dev DB never migrated — a dependency
// table belonging to a producer module — then an if-empty check on Table
// decides whether to proceed. A non-empty table means the developer already
// has local data for it: the SDK answers {skipped:true} and does NOT touch
// the table, so a developer's local writes are never clobbered by a
// re-seed. First=false chunks (a continuation for a table already accepted
// on its first chunk) skip both steps and append unconditionally — the
// seeder stops sending further chunks the moment it sees {skipped:true} on
// the first one.
//
// Table and every entry of Columns are quoted via pgx.Identifier.Sanitize
// before use in any SQL. The platform derives them from its own pg_catalog
// read (see devseed.Seeder.columns), but the SDK quotes defensively rather
// than trust the wire.
func SeedHandler(acquire SeedConnAcquirer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, seedMaxBodyBytes)

var req SeedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeBodyDecodeError(w, err)
return
}
if req.Table == "" || len(req.Columns) == 0 {
httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "table and columns are required"})
return
}
schema, ok := runtime.AppSchemaName(req.AppID)
if !ok {
httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid appId"})
return
}

ctx := db.WithSchema(r.Context(), schema)
conn, release, err := acquire(ctx)
if err != nil {
httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: "acquire db connection: " + err.Error()})
return
}
defer release()

quotedTable := pgx.Identifier{req.Table}.Sanitize()

if req.First {
if req.CreateSQL != "" {
if _, err := conn.Exec(ctx, req.CreateSQL); err != nil {
httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: "create table: " + err.Error()})
return
}
}
empty, err := seedTableIsEmpty(ctx, conn, quotedTable)
if err != nil {
httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: "check table empty: " + err.Error()})
return
}
if !empty {
// The developer's own local writes win — drop this and every
// remaining chunk for the table (the seeder stops sending on
// {skipped:true}) without touching a single row.
httputil.JSON(w, http.StatusOK, SeedResponse{Skipped: true})
return
}
}

if req.Data != "" {
quotedCols := make([]string, len(req.Columns))
for i, c := range req.Columns {
quotedCols[i] = pgx.Identifier{c}.Sanitize()
}
copySQL := fmt.Sprintf("COPY %s (%s) FROM STDIN", quotedTable, strings.Join(quotedCols, ", "))
if _, err := conn.Conn().PgConn().CopyFrom(ctx, strings.NewReader(req.Data), copySQL); err != nil {
httputil.JSON(w, http.StatusInternalServerError, httputil.ErrorResponse{Error: "copy data: " + err.Error()})
return
}
}
// Data == "" is valid: a First chunk that only needed CreateSQL to
// materialize an empty dependency table (see devseed.Seeder.seedTable
// on the platform side, the tail case for an empty own-table skip).

httputil.JSON(w, http.StatusOK, SeedResponse{})
}
}

// seedTableIsEmpty reports whether quotedTable (already identifier-quoted)
// has any row, scoped by conn's current search_path.
func seedTableIsEmpty(ctx context.Context, conn SeedConn, quotedTable string) (bool, error) {
var exists bool
if err := conn.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM "+quotedTable+" LIMIT 1)").Scan(&exists); err != nil {
return false, err
}
return !exists, nil
}
Loading
Loading