From c3cf5fcb328f2de1621bc5c56dd77e9e8dbbbe4b Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 07:37:12 +0800 Subject: [PATCH] fix(sdk): select deployed cross-read by per-request envelope, not process IsLambda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DependencyDB.Result gated the cross-read plane on the process-global runtime.IsLambda(). That is wrong altitude: whether a read is "deployed" is a PER-REQUEST property (did this invoke arrive through the deployed Lambda envelope?), not a per-process one. The local deploy-sim module-runtime serves DEPLOYED invokes over HTTP via the dev lambda-invoke shim, which is mounted ONLY when !runtime.IsLambda() — and it MUST run with IsLambda==false: setting AWS_LAMBDA_FUNCTION_NAME would make module.Start() call the real lambda.Start(), which stops the HTTP server the shim needs. So on the local shim the envelope-arrival signals (auth.PayloadTrusted + the injected Dependencies manifest) are all present, yet the IsLambda gate took the dev-tunnel PROXY branch and failed with ErrDependencyUnauthorized ("MS_INTERNAL_SECRET unset") instead of resultDeployed. Real Lambda worked (IsLambda true); only the shim was blocked — which blocked the scenario-3 (all-deployed) E2E on the local stack entirely. Gate on auth.PayloadTrusted(ctx) instead: it is set by runtime.NewLambdaHandler for BOTH the real Lambda entrypoint AND the dev shim, unconditionally per invoke, and never for a dev-tunnel HTTP request. The result() seam keeps its injected bool (renamed inLambda -> deployed) so the three planes stay explicitly testable. Behavior preserved exactly: dev-tunnel requests (no envelope) still take the read-exposed proxy; resultDeployed keeps its manifest-absent hard-error rollout gate; an all-deps-degraded deployed invoke (manifest nil but PayloadTrusted true) routes to resultDeployed and fail-closes to the typed sentinel, NOT the proxy error. Real-Lambda behavior is unchanged. Unblocks the scenario-3 local E2E on the deploy-sim shim. Co-Authored-By: Claude Fable 5 --- internal/core/dependency_db.go | 31 ++++-- ...dependency_db_deployed_integration_test.go | 2 +- internal/core/dependency_db_test.go | 94 ++++++++++++++++++- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/internal/core/dependency_db.go b/internal/core/dependency_db.go index 214d5db..cebf64d 100644 --- a/internal/core/dependency_db.go +++ b/internal/core/dependency_db.go @@ -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 @@ -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. @@ -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 diff --git a/internal/core/dependency_db_deployed_integration_test.go b/internal/core/dependency_db_deployed_integration_test.go index cb1399d..a940885 100644 --- a/internal/core/dependency_db_deployed_integration_test.go +++ b/internal/core/dependency_db_deployed_integration_test.go @@ -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) } diff --git a/internal/core/dependency_db_test.go b/internal/core/dependency_db_test.go index 8acd4f0..716af10 100644 --- a/internal/core/dependency_db_test.go +++ b/internal/core/dependency_db_test.go @@ -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) } @@ -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 @@ -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") @@ -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