From 43304ebe2d3684a10a9bc62910253c399d6a55b5 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 4 Jul 2026 21:10:54 +0800 Subject: [PATCH] feat(sdk): ms.DependencyDB restricted cross-module read client (Milestone H phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumer-side SDK client of the platform's read-exposed proxy (decision 17 §2, option (b)+(d)): a dev-plane consumer reads a producer's ExposeTable'd rows through dispatch's POST /internal/apps/{appRef}/read-exposed — a STRUCTURED read (logical table + projection + equality/IN filters), never raw SQL, never a pool. The platform authorizes against the consent+exposure catalog and executes as the consumer's own r__ role in a READ ONLY tx; the SDK joins in application code (no cross-DB SQL JOIN exists, by construction). Surface: ms.DependencyDB(ctx, "@owner/slug").Select(table).Columns(...). Where(col, v).WhereIn(col, vs...).Limit(n).Rows(ctx) / .Result(ctx) (Result adds the Truncated flag). Producer refs accept the DependsOn spec forms (@owner/slug | slug | m | UUID; trailing @constraint ignored). Builder validation mirrors the wire caps client-side (snake_case identifiers, ≤64 columns, ≤16 filters, 1..200 IN values, scalar-only filter values), latching the first error. Transport rides the existing module->dispatch seam (MS_DISPATCH_URL + dev fallback, shared callHTTP client); authn is the live tunnel session's MS_INTERNAL_SECRET sent as X-MS-Service-Secret. Failure modes are typed and fail-closed — ErrDependencyUnauthorized (401), ErrNotExposed (403 read_not_authorized), ErrDependencyUnavailable (403 dependency_unavailable + proxy-route-absent), ErrProducerNotFound (404) — never silently empty rows. Row numerics decode as json.Number so int64 join keys keep fidelity. Dev-plane only in this PR: the Lambda envelope vends no proxy credential and deployed→deployed reads use the direct GRANT via mod.DB, so deployed mode fails fast with an explicit error (follow-up wires envelope-vended proxy credentials if ever needed). Unit tests cover the wire contract via an httptest fake: happy path, every error mapping, ref parsing, builder validation, missing app scope/secret, Lambda fail-fast. Co-Authored-By: Claude Fable 5 --- internal/core/dependency_db.go | 443 ++++++++++++++++++++++++++++ internal/core/dependency_db_test.go | 421 ++++++++++++++++++++++++++ mirrorstack.go | 62 ++++ 3 files changed, 926 insertions(+) create mode 100644 internal/core/dependency_db.go create mode 100644 internal/core/dependency_db_test.go diff --git a/internal/core/dependency_db.go b/internal/core/dependency_db.go new file mode 100644 index 0000000..1d5aa47 --- /dev/null +++ b/internal/core/dependency_db.go @@ -0,0 +1,443 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + + "github.com/mirrorstack-ai/app-module-sdk/internal/runtime" +) + +// ms.DependencyDB — the RESTRICTED consumer accessor for reading a producer +// module's exposed tables (decision 17 §2, option (d): the SDK client of the +// platform read proxy). +// +// A consumer that declared ms.DependsOn("@owner/producer", n.Table("users")) +// reads those rows through the platform's read-exposed proxy — a STRUCTURED +// read (table + projection + equality/IN filters, never raw SQL) that the +// platform authorizes against the same consent+exposure catalog the +// install-time grant walk uses and executes as the consumer's own +// r__ role inside a READ ONLY transaction. There is deliberately +// NO raw SQL surface and NO pool here: the dev plane holds no socket to the +// platform DB, so a cross-plane SQL JOIN is structurally impossible — +// fetch the exposed rows, then join in application code. +// +// rows, err := ms.DependencyDB(ctx, "@owner/oauth-core"). +// Select("users"). +// Columns("id", "email"). +// Where("status", "active"). +// WhereIn("id", 1, 2, 3). +// Limit(500). +// Rows(ctx) +// +// Every failure is explicit and fail-closed (never silently empty): +// errors.Is against ErrDependencyUnauthorized / ErrNotExposed / +// ErrDependencyUnavailable / ErrProducerNotFound to branch on the +// decision-17 failure modes. +// +// DEV-PLANE ONLY today: the proxy authenticates the LIVE dev-tunnel session +// (the CLI-minted MS_INTERNAL_SECRET). A deployed consumer reads a +// co-located producer directly via mod.DB (the GRANT SELECT path); wiring +// DependencyDB for deployed consumers (envelope-vended proxy credentials) is +// a documented follow-up. + +// Sentinel errors mapping the read proxy's wire error codes onto the +// decision-17 failure modes. Match with errors.Is; the returned errors wrap +// these plus the platform's human message. +var ( + // ErrDependencyUnauthorized: the platform could not authenticate the + // read (401) — unknown app, module not resolvable in the app, no live + // tunnel session, or a wrong/unbound service secret. Deliberately one + // collapsed verdict; re-establish the tunnel session, don't retry blind. + ErrDependencyUnauthorized = errors.New("mirrorstack: dependency read unauthorized") + // ErrNotExposed: authorization failed closed (403 read_not_authorized) — + // the table is not exposed by the version the producer actually runs, + // there is no consent row, or the consumer install carries no DB role. + // The cases are deliberately indistinguishable on the wire. + ErrNotExposed = errors.New("mirrorstack: table is not exposed to this module") + // ErrDependencyUnavailable: the read was authorized but the producer's + // relation is not readable right now (403 dependency_unavailable — + // producer yanked/rolled back, grant revoked), or the platform's read + // proxy is not enabled at all. Never surfaced as empty rows. + ErrDependencyUnavailable = errors.New("mirrorstack: dependency unavailable") + // ErrProducerNotFound: the producer ref does not resolve to an install + // in this app (404 producer_not_found). + ErrProducerNotFound = errors.New("mirrorstack: producer module not found in this app") +) + +// Wire caps mirrored from the dispatch read-exposed handler so obviously +// oversized requests fail fast in-process instead of burning a round trip. +const ( + maxDependencyColumns = 64 + maxDependencyFilters = 16 + maxDependencyInValues = 200 + maxDependencyBody = 64 << 10 +) + +// dependencySQLName validates logical table / column identifiers — the same +// shape the platform enforces (lowercase snake_case, max 63 chars). Logical +// names only: never the physical m_ relation name. +var dependencySQLName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + +// Dependency is the read handle DependencyDB returns: the resolved +// (app, consumer, producer) triple every query built from it targets. +// Construction never fails — a bad ref or missing app scope is carried +// forward and surfaced by Rows/Result, keeping call sites chainable. +type Dependency struct { + consumer string // this module's Config.ID — the proxy verifies it IS the caller + producer string // producer ref resolved within the same app (slug | UUID | m) + appID string // trusted app scope from ctx (auth identity), never caller-supplied + err error // deferred construction error (bad ref, no app scope) +} + +// DependencyDB returns a read handle on producerRef's exposed tables within +// the current app. producerRef accepts the same module ref forms as +// ms.DependsOn — "@owner/slug", bare "slug", the m module ID, or a +// dashed UUID; a trailing @ is tolerated and ignored +// (reads always target the version the producer actually runs, which the +// platform resolves). The app scope is read from ctx via the SDK's auth +// identity — callers don't pass it and cannot forge it. +// +// The returned handle only builds STRUCTURED reads (Select/Columns/Where/ +// WhereIn/Limit). It is not a *pgx pool and never will be — see the package +// comment for why raw cross-plane SQL cannot exist. +func (m *Module) DependencyDB(ctx context.Context, producerRef string) *Dependency { + d := &Dependency{consumer: m.config.ID} + appID, err := appIDFromContext(ctx, "DependencyDB") + if err != nil { + d.err = err + return d + } + d.appID = appID + producer, err := parseProducerRef(producerRef) + if err != nil { + d.err = err + return d + } + d.producer = producer + return d +} + +// DependencyDB returns a read handle on the default module. Panics before +// Init — matching Platform/Public/Internal. See Module.DependencyDB. +func DependencyDB(ctx context.Context, producerRef string) *Dependency { + return mustDefault("DependencyDB").DependencyDB(ctx, producerRef) +} + +// parseProducerRef normalizes a DependsOn-style module ref to the bare form +// the proxy resolves (slug | dashed UUID | m): strips an optional +// trailing @ and an optional @owner/ prefix. The proxy resolves +// the producer WITHIN the app, where the bare ref is unambiguous. +func parseProducerRef(spec string) (string, error) { + ref := strings.TrimSpace(spec) + // Split on the LAST '@' (same rule as DependsOn's parseDepSpec) so + // "@owner/slug@^1.2" keeps its owner prefix and drops the constraint. + if at := strings.LastIndex(ref, "@"); at > 0 { + ref = ref[:at] + } + if strings.HasPrefix(ref, "@") { + slash := strings.Index(ref, "/") + if slash < 0 || slash == len(ref)-1 { + return "", fmt.Errorf("mirrorstack: DependencyDB(%q): owner-prefixed ref must be \"@owner/slug\"", spec) + } + ref = ref[slash+1:] + } + if ref == "" { + return "", fmt.Errorf("mirrorstack: DependencyDB(%q): empty module ref", spec) + } + if strings.ContainsAny(ref, "/@ \t\r\n\x00") { + return "", fmt.Errorf("mirrorstack: DependencyDB(%q): module ref %q contains invalid characters", spec, ref) + } + return ref, nil +} + +// DependencyQuery is the structured read builder. All builder methods +// validate eagerly and latch the FIRST error; Rows/Result surface it without +// touching the network. The builder is single-use and not safe for +// concurrent mutation (build, then execute). +type DependencyQuery struct { + dep *Dependency + table string + columns []string + filters map[string]any + limit int + err error +} + +// Select starts a structured read of one exposed table. name is the LOGICAL +// table name the producer declared via ms.ExposeTable (e.g. "users") — +// lowercase snake_case, never the physical prefixed relation name. +func (d *Dependency) Select(table string) *DependencyQuery { + q := &DependencyQuery{dep: d, err: d.err} + q.table = table + if q.err == nil && !dependencySQLName.MatchString(table) { + q.err = fmt.Errorf("mirrorstack: DependencyDB.Select(%q): table must be a lowercase snake_case identifier (the logical ExposeTable name)", table) + } + return q +} + +// setErr latches the first builder error. +func (q *DependencyQuery) setErr(err error) *DependencyQuery { + if q.err == nil { + q.err = err + } + return q +} + +// Columns restricts the projection to the named columns (all visible +// columns when never called). Accumulates across calls; at most 64 names. +func (q *DependencyQuery) Columns(cols ...string) *DependencyQuery { + if q.err != nil { + return q + } + for _, c := range cols { + if !dependencySQLName.MatchString(c) { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.Columns(%q): column must be a lowercase snake_case identifier", c)) + } + } + if len(q.columns)+len(cols) > maxDependencyColumns { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.Columns: at most %d columns per read", maxDependencyColumns)) + } + q.columns = append(q.columns, cols...) + return q +} + +// Where adds an equality predicate: col = value. value must be a JSON +// scalar (string, bool, or a numeric type) — the proxy's filter language is +// exactly scalar equality and scalar IN, nothing else (no NULL matching, no +// ranges, no nesting). Predicates across columns are ANDed; at most 16 +// filter columns, one predicate per column. +func (q *DependencyQuery) Where(col string, value any) *DependencyQuery { + if q.err != nil { + return q + } + if err := dependencyScalar("Where", col, value); err != nil { + return q.setErr(err) + } + return q.addFilter("Where", col, value) +} + +// WhereIn adds an IN predicate: col IN (values...). 1..200 JSON scalar +// values; an empty list is rejected (an always-false predicate is almost +// certainly a bug — fail fast rather than return silently empty rows). +func (q *DependencyQuery) WhereIn(col string, values ...any) *DependencyQuery { + if q.err != nil { + return q + } + if len(values) == 0 { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.WhereIn(%q): needs at least one value", col)) + } + if len(values) > maxDependencyInValues { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.WhereIn(%q): at most %d values", col, maxDependencyInValues)) + } + for _, v := range values { + if err := dependencyScalar("WhereIn", col, v); err != nil { + return q.setErr(err) + } + } + // Copy: the variadic backing array belongs to the caller. + return q.addFilter("WhereIn", col, append([]any(nil), values...)) +} + +// addFilter validates the column name, the one-predicate-per-column rule, +// and the filter-count cap shared by Where and WhereIn. +func (q *DependencyQuery) addFilter(op, col string, value any) *DependencyQuery { + if !dependencySQLName.MatchString(col) { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.%s(%q): column must be a lowercase snake_case identifier", op, col)) + } + if _, dup := q.filters[col]; dup { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.%s(%q): column already has a filter (one predicate per column)", op, col)) + } + if len(q.filters) >= maxDependencyFilters { + return q.setErr(fmt.Errorf("mirrorstack: DependencyDB.%s(%q): at most %d filter columns per read", op, col, maxDependencyFilters)) + } + if q.filters == nil { + q.filters = make(map[string]any) + } + q.filters[col] = value + return q +} + +// dependencyScalar accepts exactly the JSON scalars the proxy accepts: +// string, bool, and numeric types. nil, time.Time, structs, maps, and +// slices are rejected — format them to a string/number at the call site. +func dependencyScalar(op, col string, v any) error { + switch v.(type) { + case string, bool, + int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64, json.Number: + return nil + } + return fmt.Errorf("mirrorstack: DependencyDB.%s(%q): unsupported filter value type %T (string, bool, or number)", op, col, v) +} + +// Limit caps the row count. The platform defaults to 200 when unset (or +// <= 0) and hard-caps at 2000; a read cut at the limit reports +// Truncated=true via Result. +func (q *DependencyQuery) Limit(n int) *DependencyQuery { + if q.err != nil { + return q + } + q.limit = n + return q +} + +// DependencyResult is one executed read: the decoded rows plus whether the +// read was cut at the limit (more rows exist). Rows is never nil. +type DependencyResult struct { + Rows []map[string]any + Truncated bool +} + +// Rows executes the read and returns the decoded rows — one map per row, +// column name to value. Numeric values decode as json.Number (not float64) +// so int64 join keys keep full fidelity for the fetch-then-join in app code. +// Rows is never nil on success. When the read hits the limit the extra rows +// are simply absent here — use Result if you need the Truncated flag. +func (q *DependencyQuery) Rows(ctx context.Context) ([]map[string]any, error) { + res, err := q.Result(ctx) + if err != nil { + return nil, err + } + return res.Rows, nil +} + +// Result executes the read and returns rows plus the Truncated flag. +// 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()) +} + +// readExposedRequest mirrors the dispatch read-exposed wire envelope. +type readExposedRequest struct { + Module string `json:"module"` + Producer string `json:"producer"` + Table string `json:"table"` + Columns []string `json:"columns,omitempty"` + Filters map[string]any `json:"filters,omitempty"` + 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) { + if q.err != nil { + return nil, q.err + } + if inLambda { + // The proxy authenticates a LIVE dev-tunnel session; a deployed + // consumer has none. Deployed->deployed reads use the direct GRANT + // via mod.DB (decision 17 resolution matrix); vending proxy + // credentials to deployed consumers is a documented follow-up. + return nil, errors.New("mirrorstack: DependencyDB is dev-plane only — a deployed module reads a co-located producer's exposed tables via mod.DB (GRANT SELECT); cross-plane deployed reads are not supported") + } + // The proxy binds the caller to its live tunnel session by the session's + // InternalSecret — the exact value the CLI exports as MS_INTERNAL_SECRET + // (the same seam the module-log ingest rides). Deliberately NOT the + // MS_PLATFORM_TOKEN hierarchy: that is a different per-session credential + // for inbound proxy validation, not this session-identity seam. + secret := os.Getenv("MS_INTERNAL_SECRET") + if secret == "" { + return nil, fmt.Errorf("%w: no dev-tunnel session secret (MS_INTERNAL_SECRET is unset — run under `mirrorstack dev --tunnel`)", ErrDependencyUnauthorized) + } + + payload := readExposedRequest{ + Module: q.dep.consumer, + Producer: q.dep.producer, + Table: q.table, + Columns: q.columns, + Filters: q.filters, + Limit: q.limit, + } + buf, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("mirrorstack: DependencyDB: marshal request: %w", err) + } + if len(buf) > maxDependencyBody { + return nil, fmt.Errorf("mirrorstack: DependencyDB: request envelope exceeds %d bytes (shrink the filter lists)", maxDependencyBody) + } + + // Same dispatch base + HTTP client every module->dispatch surface uses + // (ms.Call / ms.Emit / meter) — one transport config, no second seam. + u := fmt.Sprintf("%s/internal/apps/%s/read-exposed", dispatchBase(), url.PathEscape(q.dep.appID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-MS-Service-Secret", secret) + + resp, err := callHTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("mirrorstack: DependencyDB: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, mapReadExposedError(resp.StatusCode, body) + } + + // UseNumber: row values must round-trip int64 keys losslessly for the + // app-code join; default float64 decoding would corrupt large IDs. + dec := json.NewDecoder(resp.Body) + dec.UseNumber() + var out struct { + Rows []map[string]any `json:"rows"` + Truncated bool `json:"truncated"` + } + if err := dec.Decode(&out); err != nil { + return nil, fmt.Errorf("mirrorstack: DependencyDB: decode response: %w", err) + } + if out.Rows == nil { + out.Rows = []map[string]any{} + } + return &DependencyResult{Rows: out.Rows, Truncated: out.Truncated}, nil +} + +// mapReadExposedError translates the proxy's error envelope +// ({"error":{"code","message"}}) into the typed sentinels. Fail-closed by +// construction: anything unrecognized is still an error, never empty rows. +func mapReadExposedError(status int, body []byte) error { + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + code, msg := "", "" + if json.Unmarshal(body, &env) == nil { + code, msg = env.Error.Code, env.Error.Message + } + if msg == "" { + msg = strings.TrimSpace(string(body)) + } + switch { + case status == http.StatusUnauthorized: + return fmt.Errorf("%w: %s (re-establish the dev tunnel session)", ErrDependencyUnauthorized, msg) + case status == http.StatusForbidden && code == "dependency_unavailable": + return fmt.Errorf("%w: %s", ErrDependencyUnavailable, msg) + case status == http.StatusForbidden: + // read_not_authorized (and any other 403): not exposed by the running + // version, no consent, or no consumer role — deliberately collapsed. + return fmt.Errorf("%w: %s", ErrNotExposed, msg) + case status == http.StatusNotFound && code == "producer_not_found": + return fmt.Errorf("%w: %s", ErrProducerNotFound, msg) + case status == http.StatusNotFound || status == http.StatusMethodNotAllowed: + // No read-exposed route at all: the platform's proxy is disabled + // (dispatch not configured for cross-DB reads). Fail closed. + return fmt.Errorf("%w: read proxy is not enabled on this platform (%d: %s)", ErrDependencyUnavailable, status, msg) + default: + return fmt.Errorf("mirrorstack: DependencyDB: read-exposed -> %d %s: %s", status, code, msg) + } +} diff --git a/internal/core/dependency_db_test.go b/internal/core/dependency_db_test.go new file mode 100644 index 0000000..0b692d7 --- /dev/null +++ b/internal/core/dependency_db_test.go @@ -0,0 +1,421 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mirrorstack-ai/app-module-sdk/auth" +) + +// depTestModule builds a module + app-scoped ctx wired at a fake dispatch. +func depTestModule(t *testing.T, srvURL string) (*Module, context.Context) { + t.Helper() + t.Setenv("MS_DISPATCH_URL", srvURL) + t.Setenv("MS_INTERNAL_SECRET", "sess-secret-1") + m, err := New(Config{ID: "m1234abcd"}) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := auth.Set(context.Background(), auth.Identity{AppID: "app-uuid-1", UserID: "u1", AppRole: auth.RoleAdmin}) + return m, ctx +} + +// fakeReadExposed serves the wire contract: asserts nothing, records the +// request, and replies with the given status + body. +func fakeReadExposed(t *testing.T, status int, respBody string) (*httptest.Server, *http.Request, *[]byte) { + t.Helper() + var gotReq http.Request + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotReq = *r.Clone(r.Context()) + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(respBody)) + })) + t.Cleanup(srv.Close) + return srv, &gotReq, &gotBody +} + +func TestDependencyDB_HappyPath(t *testing.T) { + srv, gotReq, gotBody := fakeReadExposed(t, http.StatusOK, + `{"rows":[{"id":9007199254740993,"email":"a@b.c","active":true}],"truncated":true}`) + m, ctx := depTestModule(t, srv.URL) + + res, err := m.DependencyDB(ctx, "@anna/oauth-core"). + Select("users"). + Columns("id", "email"). + Where("status", "active"). + WhereIn("id", 1, 2). + Limit(500). + Result(ctx) + if err != nil { + t.Fatalf("Result: %v", err) + } + + // Wire shape: method, path, headers. + if gotReq.Method != http.MethodPost { + t.Errorf("method = %q, want POST", gotReq.Method) + } + if gotReq.URL.Path != "/internal/apps/app-uuid-1/read-exposed" { + t.Errorf("path = %q, want /internal/apps/app-uuid-1/read-exposed", gotReq.URL.Path) + } + if got := gotReq.Header.Get("X-MS-Service-Secret"); got != "sess-secret-1" { + t.Errorf("X-MS-Service-Secret = %q, want sess-secret-1", got) + } + if got := gotReq.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q", got) + } + + // Request envelope: consumer=Config.ID, producer stripped to bare slug, + // equality scalar + IN array filters, projection, limit. + var body struct { + Module string `json:"module"` + Producer string `json:"producer"` + Table string `json:"table"` + Columns []string `json:"columns"` + Filters map[string]any `json:"filters"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(*gotBody, &body); err != nil { + t.Fatalf("unmarshal request body: %v (%s)", err, *gotBody) + } + if body.Module != "m1234abcd" { + t.Errorf("module = %q, want m1234abcd (the caller's Config.ID)", body.Module) + } + if body.Producer != "oauth-core" { + t.Errorf("producer = %q, want oauth-core (owner prefix stripped)", body.Producer) + } + if body.Table != "users" { + t.Errorf("table = %q, want users", body.Table) + } + if len(body.Columns) != 2 || body.Columns[0] != "id" || body.Columns[1] != "email" { + t.Errorf("columns = %v, want [id email]", body.Columns) + } + if got := body.Filters["status"]; got != "active" { + t.Errorf("filters.status = %v, want \"active\"", got) + } + if in, ok := body.Filters["id"].([]any); !ok || len(in) != 2 { + t.Errorf("filters.id = %v, want a 2-element array", body.Filters["id"]) + } + if body.Limit != 500 { + t.Errorf("limit = %d, want 500", body.Limit) + } + + // Decoded result: json.Number fidelity + truncated flag. + if len(res.Rows) != 1 { + t.Fatalf("rows = %d, want 1", len(res.Rows)) + } + if !res.Truncated { + t.Errorf("truncated = false, want true") + } + id, ok := res.Rows[0]["id"].(json.Number) + if !ok { + t.Fatalf("id decoded as %T, want json.Number (int64 fidelity)", res.Rows[0]["id"]) + } + if id.String() != "9007199254740993" { + t.Errorf("id = %s, want 9007199254740993 (would corrupt as float64)", id) + } + if res.Rows[0]["email"] != "a@b.c" || res.Rows[0]["active"] != true { + t.Errorf("row = %v", res.Rows[0]) + } +} + +func TestDependencyDB_EmptyRowsNeverNil(t *testing.T) { + srv, _, _ := fakeReadExposed(t, http.StatusOK, `{"rows":[],"truncated":false}`) + m, ctx := depTestModule(t, srv.URL) + + rows, err := m.DependencyDB(ctx, "oauth-core").Select("users").Rows(ctx) + if err != nil { + t.Fatalf("Rows: %v", err) + } + if rows == nil { + t.Fatalf("rows is nil, want non-nil empty slice") + } + if len(rows) != 0 { + t.Errorf("rows = %v, want empty", rows) + } +} + +func TestDependencyDB_ErrorMapping(t *testing.T) { + cases := []struct { + name string + status int + body string + wantErr error + }{ + { + name: "401 unauthorized", + status: http.StatusUnauthorized, + body: `{"error":{"code":"unauthorized","message":"invalid read-exposed credentials"}}`, + wantErr: ErrDependencyUnauthorized, + }, + { + name: "403 read_not_authorized", + status: http.StatusForbidden, + body: `{"error":{"code":"read_not_authorized","message":"cross-module read is not authorized"}}`, + wantErr: ErrNotExposed, + }, + { + name: "403 dependency_unavailable", + status: http.StatusForbidden, + body: `{"error":{"code":"dependency_unavailable","message":"producer table is not readable"}}`, + wantErr: ErrDependencyUnavailable, + }, + { + name: "404 producer_not_found", + status: http.StatusNotFound, + body: `{"error":{"code":"producer_not_found","message":"producer module not found"}}`, + wantErr: ErrProducerNotFound, + }, + { + name: "404 without envelope = proxy route absent, fail closed", + status: http.StatusNotFound, + body: "404 page not found", + wantErr: ErrDependencyUnavailable, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, _, _ := fakeReadExposed(t, tc.status, tc.body) + m, ctx := depTestModule(t, srv.URL) + + rows, err := m.DependencyDB(ctx, "oauth-core").Select("users").Rows(ctx) + if rows != nil { + t.Errorf("rows = %v, want nil on error (never silently empty)", rows) + } + if !errors.Is(err, tc.wantErr) { + t.Errorf("err = %v, want errors.Is(%v)", err, tc.wantErr) + } + }) + } +} + +func TestDependencyDB_500IsGenericError(t *testing.T) { + srv, _, _ := fakeReadExposed(t, http.StatusInternalServerError, + `{"error":{"code":"internal_error","message":"boom"}}`) + m, ctx := depTestModule(t, srv.URL) + + _, err := m.DependencyDB(ctx, "oauth-core").Select("users").Rows(ctx) + if err == nil { + t.Fatalf("err = nil, want error") + } + for _, sentinel := range []error{ErrDependencyUnauthorized, ErrNotExposed, ErrDependencyUnavailable, ErrProducerNotFound} { + if errors.Is(err, sentinel) { + t.Errorf("500 mapped to sentinel %v; want generic error", sentinel) + } + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("err = %v, want platform message included", err) + } +} + +func TestDependencyDB_ProducerRefParsing(t *testing.T) { + cases := []struct { + ref string + want string + }{ + {"@anna/oauth-core", "oauth-core"}, + {"@anna/oauth-core@^1.2.0", "oauth-core"}, + {"oauth-core@^1", "oauth-core"}, + {"oauth-core", "oauth-core"}, + {"m0e37bd82f0f5427a80549b6a5aebd3a8", "m0e37bd82f0f5427a80549b6a5aebd3a8"}, + {"0e37bd82-f0f5-427a-8054-9b6a5aebd3a8", "0e37bd82-f0f5-427a-8054-9b6a5aebd3a8"}, + } + for _, tc := range cases { + got, err := parseProducerRef(tc.ref) + if err != nil { + t.Errorf("parseProducerRef(%q): %v", tc.ref, err) + continue + } + if got != tc.want { + t.Errorf("parseProducerRef(%q) = %q, want %q", tc.ref, got, tc.want) + } + } + + for _, bad := range []string{"", " ", "@anna", "@anna/", "@", "a b", "a/b"} { + if got, err := parseProducerRef(bad); err == nil { + t.Errorf("parseProducerRef(%q) = %q, want error", bad, got) + } + } +} + +func TestDependencyDB_BuilderValidation(t *testing.T) { + // No server: every case must fail BEFORE the network. + m, ctx := depTestModule(t, "http://127.0.0.1:1") // unroutable — a request would error differently + + many := make([]string, 65) + for i := range many { + many[i] = "cx" + } + + cases := []struct { + name string + build func() *DependencyQuery + frag string // expected error fragment + }{ + { + name: "empty table", + build: func() *DependencyQuery { return m.DependencyDB(ctx, "oauth-core").Select("") }, + frag: "table must be", + }, + { + name: "uppercase table", + build: func() *DependencyQuery { return m.DependencyDB(ctx, "oauth-core").Select("Users") }, + frag: "table must be", + }, + { + name: "physical-looking injection table", + build: func() *DependencyQuery { return m.DependencyDB(ctx, "oauth-core").Select("users; drop table x") }, + frag: "table must be", + }, + { + name: "bad ref", + build: func() *DependencyQuery { return m.DependencyDB(ctx, "@anna").Select("users") }, + frag: "owner-prefixed ref", + }, + { + name: "bad column", + build: func() *DependencyQuery { + return m.DependencyDB(ctx, "oauth-core").Select("users").Columns("id", "e-mail") + }, + frag: "column must be", + }, + { + name: "too many columns", + build: func() *DependencyQuery { + return m.DependencyDB(ctx, "oauth-core").Select("users").Columns(many...) + }, + frag: "at most 64 columns", + }, + { + name: "nil filter value", + build: func() *DependencyQuery { + return m.DependencyDB(ctx, "oauth-core").Select("users").Where("status", nil) + }, + frag: "unsupported filter value", + }, + { + name: "struct filter value", + build: func() *DependencyQuery { + return m.DependencyDB(ctx, "oauth-core").Select("users").Where("status", struct{ X int }{1}) + }, + frag: "unsupported filter value", + }, + { + name: "empty WhereIn", + build: func() *DependencyQuery { + return m.DependencyDB(ctx, "oauth-core").Select("users").WhereIn("id") + }, + frag: "at least one value", + }, + { + name: "oversized WhereIn", + build: func() *DependencyQuery { + vals := make([]any, 201) + for i := range vals { + vals[i] = i + } + return m.DependencyDB(ctx, "oauth-core").Select("users").WhereIn("id", vals...) + }, + frag: "at most 200 values", + }, + { + name: "duplicate filter column", + build: func() *DependencyQuery { + return m.DependencyDB(ctx, "oauth-core").Select("users").Where("id", 1).WhereIn("id", 2, 3) + }, + frag: "one predicate per column", + }, + { + name: "too many filters", + build: func() *DependencyQuery { + q := m.DependencyDB(ctx, "oauth-core").Select("users") + for i := 0; i < 17; i++ { + q = q.Where("col_"+string(rune('a'+i)), i) + } + return q + }, + frag: "at most 16 filter columns", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rows, err := tc.build().Rows(ctx) + if err == nil { + t.Fatalf("err = nil, want builder validation error") + } + if rows != nil { + t.Errorf("rows = %v, want nil", rows) + } + if !strings.Contains(err.Error(), tc.frag) { + t.Errorf("err = %v, want fragment %q", err, tc.frag) + } + }) + } +} + +func TestDependencyDB_FirstBuilderErrorWins(t *testing.T) { + m, ctx := depTestModule(t, "http://127.0.0.1:1") + + _, err := m.DependencyDB(ctx, "oauth-core"). + Select("Users"). // first error: bad table + Columns("also-bad-col"). // would be a second error + Where("x", struct{}{}). // and a third + Rows(ctx) + if err == nil || !strings.Contains(err.Error(), "table must be") { + t.Errorf("err = %v, want the FIRST error (bad table)", err) + } +} + +func TestDependencyDB_RequiresAppScope(t *testing.T) { + srv, _, _ := fakeReadExposed(t, http.StatusOK, `{"rows":[],"truncated":false}`) + m, _ := depTestModule(t, srv.URL) + + // Context with no auth identity: no app scope. + _, err := m.DependencyDB(context.Background(), "oauth-core").Select("users").Rows(context.Background()) + if err == nil || !strings.Contains(err.Error(), "app-scoped context") { + t.Errorf("err = %v, want app-scoped-context error", err) + } +} + +func TestDependencyDB_RequiresTunnelSecret(t *testing.T) { + srv, _, _ := fakeReadExposed(t, http.StatusOK, `{"rows":[],"truncated":false}`) + m, ctx := depTestModule(t, srv.URL) + t.Setenv("MS_INTERNAL_SECRET", "") + + _, err := m.DependencyDB(ctx, "oauth-core").Select("users").Rows(ctx) + if !errors.Is(err, ErrDependencyUnauthorized) { + t.Errorf("err = %v, want ErrDependencyUnauthorized (fail closed without the session secret)", err) + } +} + +func TestDependencyDB_LambdaModeFailsFast(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 */) + if err == nil || !strings.Contains(err.Error(), "dev-plane only") { + t.Errorf("err = %v, want dev-plane-only fail-fast", err) + } +} + +func TestDependencyDB_PackageLevelPanicsBeforeInit(t *testing.T) { + prev := defaultModule + defaultModule = nil + t.Cleanup(func() { defaultModule = prev }) + + defer func() { + if r := recover(); r == nil { + t.Errorf("DependencyDB before Init did not panic") + } + }() + DependencyDB(context.Background(), "oauth-core") +} diff --git a/mirrorstack.go b/mirrorstack.go index a35dbd2..b15e72e 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -436,6 +436,68 @@ func ContributesTo(host, slot string, payload any) { core.ContributesTo(host, sl // invalid table identifier. Call from startup code. func ExposeTable(name string) { core.ExposeTable(name) } +// --- Dependency reads (exposed tables, via the platform read proxy) --- + +// Dependency is the read handle DependencyDB returns — the consumer side of +// ExposeTable at runtime. It builds STRUCTURED reads only (never raw SQL, +// never a pool): Select(table).Columns(...).Where(...).WhereIn(...).Limit(n). +type Dependency = core.Dependency + +// DependencyQuery is the structured read builder started by +// Dependency.Select. Builder methods validate eagerly and latch the first +// error; Rows/Result surface it without touching the network. +type DependencyQuery = core.DependencyQuery + +// DependencyResult is one executed dependency read: decoded rows (never nil) +// plus whether the read was cut at the limit (more rows exist). +type DependencyResult = core.DependencyResult + +// Typed failure modes for dependency reads — match with errors.Is. All +// fail-closed: a dependency read NEVER silently returns empty rows for an +// authorization or availability failure. +var ( + // ErrDependencyUnauthorized — the platform could not authenticate the + // read (no live dev-tunnel session / wrong service secret / module not + // in the app). Re-establish the tunnel session. + ErrDependencyUnauthorized = core.ErrDependencyUnauthorized + // ErrNotExposed — the table is not exposed to this module by the version + // the producer actually runs, or the app owner never consented. + ErrNotExposed = core.ErrNotExposed + // ErrDependencyUnavailable — authorized, but the producer's relation is + // not readable right now (yanked/rolled back), or the platform's read + // proxy is disabled. + ErrDependencyUnavailable = core.ErrDependencyUnavailable + // ErrProducerNotFound — the producer ref does not resolve to an install + // in this app. + ErrProducerNotFound = core.ErrProducerNotFound +) + +// DependencyDB returns a read-only handle on a producer module's exposed +// tables within the current app — the runtime counterpart of +// ms.DependsOn(..., n.Table(...)). producerRef takes the same forms as +// DependsOn specs: "@owner/slug", bare "slug", the m module ID, or a +// dashed UUID (a trailing @ is ignored — reads target the +// version the producer actually runs). The app scope is read from ctx via +// the SDK's auth identity. +// +// rows, err := ms.DependencyDB(ctx, "@owner/oauth-core"). +// Select("users"). +// Columns("id", "email"). +// WhereIn("id", 1, 2, 3). +// Rows(ctx) +// +// The read executes on the PLATFORM as this module's own per-app DB role in +// a READ ONLY transaction, authorized against the consent+exposure catalog. +// There is no cross-plane SQL JOIN — fetch the exposed rows here, read your +// own tables via mod.DB, and join in application code. +// +// Dev-plane (`mirrorstack dev --tunnel`) only today; a deployed module reads +// a co-located producer directly via mod.DB (GRANT SELECT). Panics before +// Init — matching Platform/Public/Internal. +func DependencyDB(ctx context.Context, producerRef string) *Dependency { + return core.DependencyDB(ctx, producerRef) +} + // --- UI surface --- // ModuleUI is the module's declared UI surface. Pass to ms.RegisterUI.