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
31 changes: 24 additions & 7 deletions internal/core/dependency_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"

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

// ms.DependencyDB — the RESTRICTED consumer accessor for reading a producer
// module's exposed tables. PLANE-TRANSPARENT: the same call works on both
// planes and the switch lives entirely inside result(ctx, runtime.IsLambda()).
// planes and the switch lives entirely inside result(ctx, deployed) — where
// `deployed` is the PER-REQUEST deployed-envelope signal (auth.PayloadTrusted),
// NOT the process-global runtime.IsLambda(). The envelope signal is set for
// every deployed invoke on BOTH the real Lambda entrypoint and the local dev
// lambda-invoke shim, so the deployed read fires in the shim too — not only in
// real Lambda (where IsLambda would be true).
//
// A consumer that declared ms.DependsOn("@owner/producer", n.Table("users"))
// issues a STRUCTURED read (table + projection + equality/IN filters, never
Expand Down Expand Up @@ -329,7 +334,15 @@ func (q *DependencyQuery) Rows(ctx context.Context) ([]map[string]any, error) {
// Failure modes are typed and fail-closed (never silently empty) — see the
// Err* sentinels.
func (q *DependencyQuery) Result(ctx context.Context) (*DependencyResult, error) {
return q.result(ctx, runtime.IsLambda())
// The plane is a PER-REQUEST property: did THIS invoke arrive through the
// deployed Lambda envelope? auth.PayloadTrusted is that signal — set by
// runtime.NewLambdaHandler for both real Lambda AND the dev lambda-invoke
// shim, and never for a dev-tunnel HTTP request. Deliberately NOT the
// process-global runtime.IsLambda(): the local deploy-sim shim serves
// deployed invokes over HTTP with IsLambda==false (it cannot set
// AWS_LAMBDA_FUNCTION_NAME without lambda.Start() hijacking the process), so
// keying on IsLambda would wrongly divert the shim to the dev-tunnel proxy.
return q.result(ctx, auth.PayloadTrusted(ctx))
}

// readExposedRequest mirrors the dispatch read-exposed wire envelope.
Expand All @@ -342,13 +355,17 @@ type readExposedRequest struct {
Limit int `json:"limit,omitempty"`
}

// result is the test seam behind Result (inLambda injected so tests don't
// depend on process env captured at package init).
func (q *DependencyQuery) result(ctx context.Context, inLambda bool) (*DependencyResult, error) {
// result is the test seam behind Result. deployed is TRUE when the invoke
// arrived through the deployed Lambda envelope (auth.PayloadTrusted, set by
// runtime.NewLambdaHandler for BOTH real Lambda and the dev lambda-invoke
// shim) — a PER-REQUEST property, injected here so the plane choice is testable
// and never keys on the process-global runtime.IsLambda() (which is false
// inside the local shim's HTTP server). deployed=false is the dev-tunnel path.
func (q *DependencyQuery) result(ctx context.Context, deployed bool) (*DependencyResult, error) {
if q.err != nil {
return nil, q.err
}
if inLambda {
if deployed {
return q.resultDeployed(ctx)
}
// The proxy binds the caller to its live tunnel session by the session's
Expand Down
2 changes: 1 addition & 1 deletion internal/core/dependency_db_deployed_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func TestDependencyDB_DeployedRead_Integration(t *testing.T) {
Select("users").
Columns("id", "email", "deleted_at").
Limit(1).
result(readCtx, true /* inLambda */)
result(readCtx, true /* deployed */)
if err != nil {
t.Fatalf("deployed read: %v", err)
}
Expand Down
94 changes: 91 additions & 3 deletions internal/core/dependency_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,12 +399,12 @@ func TestDependencyDB_RequiresTunnelSecret(t *testing.T) {
}
}

func TestDependencyDB_LambdaModeFailsFast(t *testing.T) {
func TestDependencyDB_DeployedSeamFailsFastWithoutManifest(t *testing.T) {
srv, _, _ := fakeReadExposed(t, http.StatusOK, `{"rows":[],"truncated":false}`)
m, ctx := depTestModule(t, srv.URL)

q := m.DependencyDB(ctx, "oauth-core").Select("users")
_, err := q.result(ctx, true /* inLambda */)
_, err := q.result(ctx, true /* deployed */)
if err == nil || !strings.Contains(err.Error(), "dev-plane only") {
t.Errorf("err = %v, want dev-plane-only fail-fast", err)
}
Expand All @@ -424,7 +424,7 @@ func TestDependencyDB_PackageLevelPanicsBeforeInit(t *testing.T) {
}

// ---------------------------------------------------------------------------
// Deployed plane (decision 18 §3): the inLambda branch reads the injected
// Deployed plane (decision 18 §3): the deployed branch reads the injected
// manifest. These cover the §5 sentinel matrix cells that fail closed BEFORE
// touching a pool (manifest-absent, ref-absent, table-absent) plus the
// read-time SQLSTATE mapping. The happy-path read + live 42P01/42501 live in
Expand All @@ -433,6 +433,9 @@ func TestDependencyDB_PackageLevelPanicsBeforeInit(t *testing.T) {

// deployedCtx builds an app-scoped context carrying the given dependency
// manifest, as the Lambda invoke shim would (db.WithSchema + db.WithDependencies).
// It does NOT set auth.WithPayloadTrust — the seam tests drive the plane via the
// result() bool directly; the public-API plane tests wrap it in WithPayloadTrust
// to model the envelope-arrival signal runtime.NewLambdaHandler sets.
func deployedCtx(manifest []db.DependencyGrant) context.Context {
ctx := auth.Set(context.Background(), auth.Identity{AppID: "app-uuid-1", UserID: "u1", AppRole: auth.RoleAdmin})
ctx = db.WithSchema(ctx, "app_283e0ef9_1a2b_3c4d_5e6f_0123456789ab")
Expand Down Expand Up @@ -487,6 +490,91 @@ func TestDependencyDB_DeployedTableNotExposed(t *testing.T) {
}
}

// TestDependencyDB_Result_DeployedFiresInLocalShimNotOnlyRealLambda is the
// regression pin for the plane-gate bug: the deployed cross-read must be
// selected by the PER-REQUEST deployed-envelope signal (auth.PayloadTrusted,
// which runtime.NewLambdaHandler sets for BOTH the real Lambda entrypoint AND
// the local dev lambda-invoke shim), NOT by the process-global
// runtime.IsLambda(). Before the fix the local deploy-sim shim served deployed
// invokes over HTTP with IsLambda==false — because the process cannot set
// AWS_LAMBDA_FUNCTION_NAME without lambda.Start() hijacking the HTTP server — so
// the gate wrongly took the dev-tunnel proxy branch and failed with
// ErrDependencyUnauthorized ("MS_INTERNAL_SECRET unset") instead of reading the
// injected manifest.
//
// Every case uses the SAME ctx data (a manifest that resolves the producer but
// does NOT expose "users"); ONLY the per-request envelope mark differs. A
// deployed selection fails closed with ErrNotExposed BEFORE any pool/network; a
// proxy selection instead POSTs to dispatch. The whole test process has
// IsLambda==false, so the branch each case lands on proves the gate reads the
// per-request envelope, not the process flag.
func TestDependencyDB_Result_DeployedFiresInLocalShimNotOnlyRealLambda(t *testing.T) {
manifest := []db.DependencyGrant{
{Ref: "oauth-core", Tables: map[string]string{"sessions": "m81b3ac70_sessions"}},
}

// (a) dev-sim shim: deployed envelope present, process IsLambda==false. This
// is the exact scenario the bug blocked. No tunnel secret, so a WRONG proxy
// selection would surface ErrDependencyUnauthorized — the pre-fix failure.
t.Run("dev-sim shim deployed invoke (envelope set, IsLambda false) -> resultDeployed", func(t *testing.T) {
t.Setenv("MS_INTERNAL_SECRET", "")
m, err := New(Config{ID: "m1234abcd"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := auth.WithPayloadTrust(deployedCtx(manifest)) // exactly what the shim's NewLambdaHandler sets
_, err = m.DependencyDB(ctx, "@mirrorstack/oauth-core").Select("users").Result(ctx)
if !errors.Is(err, ErrNotExposed) {
t.Fatalf("err = %v, want ErrNotExposed (deployed branch selected by the per-request envelope)", err)
}
if errors.Is(err, ErrDependencyUnauthorized) {
t.Errorf("routed to the dev-tunnel PROXY branch despite the deployed envelope — the local shim (IsLambda false) must still read deployed")
}
})

// (c) real Lambda enters via the SAME runtime.NewLambdaHandler, so it carries
// the identical payload-trust mark. The fixed gate is per-request, so real
// Lambda and the shim route identically — the process IsLambda flag (true
// only in real Lambda) is deliberately not consulted. That identity IS the fix.
t.Run("real-lambda deployed invoke (same envelope mark) -> resultDeployed", func(t *testing.T) {
t.Setenv("MS_INTERNAL_SECRET", "")
m, err := New(Config{ID: "m1234abcd"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := auth.WithPayloadTrust(deployedCtx(manifest))
_, err = m.DependencyDB(ctx, "@mirrorstack/oauth-core").Select("users").Result(ctx)
if !errors.Is(err, ErrNotExposed) {
t.Fatalf("err = %v, want ErrNotExposed (deployed branch)", err)
}
})

// (b) dev-tunnel request: NO envelope mark (PayloadTrusted==false), so the
// gate takes the read-exposed PROXY — it ignores the manifest and POSTs to
// dispatch. A fake server proves the proxy branch ran end to end. Scenario
// 1/2 is preserved unchanged.
t.Run("dev-tunnel request (no envelope) -> read-exposed PROXY", func(t *testing.T) {
srv, gotReq, _ := fakeReadExposed(t, http.StatusOK, `{"rows":[{"id":1}],"truncated":false}`)
t.Setenv("MS_DISPATCH_URL", srv.URL)
t.Setenv("MS_INTERNAL_SECRET", "sess-secret-1")
m, err := New(Config{ID: "m1234abcd"})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := deployedCtx(manifest) // manifest present but NO auth.WithPayloadTrust
res, err := m.DependencyDB(ctx, "@mirrorstack/oauth-core").Select("users").Result(ctx)
if err != nil {
t.Fatalf("proxy read: %v (want a successful dev-tunnel proxy read)", err)
}
if gotReq.URL.Path != "/internal/apps/app-uuid-1/read-exposed" {
t.Errorf("path = %q, want the read-exposed proxy path (proxy branch taken)", gotReq.URL.Path)
}
if len(res.Rows) != 1 {
t.Errorf("rows = %d, want 1 from the proxied server", len(res.Rows))
}
})
}

func TestMapDeployedReadError(t *testing.T) {
cases := []struct {
name string
Expand Down
Loading