diff --git a/CHANGELOG.md b/CHANGELOG.md index 8196a17..3b3a41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [v0.2.5] - 2026-06-19 + +A module can now mark one of its own tables **read-only eligible** for a depending module — the producer half of the cross-module data contract. v0.2.0's design notes deferred this in favor of `pg_class` introspection; an explicit declaration is clearer (the producer's intent is in source, not inferred) and keeps the GRANT surface auditable, while preserving the same trust model: the producer marks a table readable, the **app owner** decides who reads it. + +### Added +- **`ms.ExposeTable(name string)`** — a zero-runtime DECLARATION that marks a table in the module's `mod_` schema as SELECT-eligible for a depending module (the producer side of `ms.DependsOn`'s `n.Table`). It surfaces in the manifest under a new top-level `exposes` block — `"exposes": { "tables": [...] }` — a flat, **sorted, de-duplicated** list of table names. The platform catalog issues `GRANT SELECT` against a depending module's DB role only after the **app owner** approves that dependency. v1 is **TABLES ONLY, read-only**. There is intentionally **no per-consumer `readableBy` allowlist**: in a marketplace the consumers are third parties, so a publisher-controlled reader list is the wrong trust model — the producer opts a table *in* to being readable, the app owner (the trust root) decides *who* reads. Repeated/feature-flagged declarations of the same name compose safely (set union); an empty or non-identifier-shaped name (`^[a-z][a-z0-9_]{0,62}$`, the Postgres NAMEDATALEN ceiling) panics at startup. The manifest always carries `exposes` (an empty `tables` array when the module exposes nothing). + ## [v0.2.4] - 2026-06-17 The usage-meter transport moves from an AWS Lambda invoke to a dispatch-HTTP POST, exactly mirroring `ms.Emit`. The public metering API (`ms.Meter` declaration, `ms.Record` emit-by-name, the v1 envelope with no kind on the wire, the reserved-namespace guards) is unchanged — only how a recorded event reaches the platform changes. diff --git a/README.md b/README.md index 896a3e7..643c53c 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,7 @@ Keys auto-prefixed: developer writes `"views:123"`, Redis stores `"app_abc123:mo - [x] `ms.Describe()` — module description for agent discovery - [x] `ms.DependsOn()` — dependency declaration with auto-detected required/optional +- [x] `ms.ExposeTable()` — mark a table read-only (SELECT) eligible for depending modules; app owner approves who reads (no producer allowlist) - [x] `ms.Resolve[T]()` — typed runtime lookup for optional deps (stub pending cross-module wiring) - [x] `ms.MCPTool()` — agent-callable tool with JSON Schema derivation - [x] `ms.MCPResource()` — agent-readable resource diff --git a/VERSION b/VERSION index abd4105..3a4036f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.4 +0.2.5 diff --git a/internal/core/expose.go b/internal/core/expose.go new file mode 100644 index 0000000..1280346 --- /dev/null +++ b/internal/core/expose.go @@ -0,0 +1,29 @@ +package core + +// ExposeTable marks a table in this module's `mod_` schema as eligible to +// be read (SELECT) by a depending module. It is a pure DECLARATION — no +// runtime, no return value — recorded in the manifest under +// `exposes.tables` so the platform catalog can issue GRANT SELECT after the +// app owner approves a dependency. +// +// v1 is TABLES ONLY, read-only. The producer marks a table READABLE; it does +// NOT name WHO reads it. The app owner — the trust root — decides which +// installed modules may read by approving their declared dependency. There is +// intentionally no per-consumer allowlist here: a marketplace's consumers are +// third parties, so a publisher-controlled reader list is the wrong trust +// model. +// +// Panics on an empty or otherwise invalid table identifier (lowercase, leading +// letter, [a-z0-9_], <=63 chars). Call from startup code, not a request +// handler. +// +// ms.ExposeTable("orders") +func (m *Module) ExposeTable(name string) { + m.registry.AddExposedTable(name) +} + +// ExposeTable declares an exposed table on the default Module created by +// Init(). Panics before Init — matches Platform/Public/Internal/Emits. +func ExposeTable(name string) { + mustDefault("ExposeTable").ExposeTable(name) +} diff --git a/internal/core/expose_test.go b/internal/core/expose_test.go new file mode 100644 index 0000000..02d62cc --- /dev/null +++ b/internal/core/expose_test.go @@ -0,0 +1,71 @@ +package core + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/mirrorstack-ai/app-module-sdk/system" +) + +func TestExposeTable_RecordsInRegistry(t *testing.T) { + t.Parallel() + + m, _ := New(Config{ID: "media"}) + m.ExposeTable("orders") + m.ExposeTable("invoices") + m.ExposeTable("orders") // dup, dropped + + got := m.registry.ExposedTables() + if !slices.Equal(got, []string{"invoices", "orders"}) { + t.Errorf("ExposedTables() = %v, want sorted [invoices orders]", got) + } +} + +func TestExposeTable_PanicsOnInvalidName(t *testing.T) { + t.Parallel() + + m, _ := New(Config{ID: "media"}) + assertPanics(t, "expected panic on invalid exposed table name", func() { + m.ExposeTable("") + }) +} + +func TestExposeTable_AppearsInManifestExposes(t *testing.T) { + m := newTestModuleWithSecret(t, "media") + + m.ExposeTable("orders") + m.ExposeTable("invoices") + + rec := doRequestWithSecret(t, m.Router(), "GET", "/__mirrorstack/platform/manifest", "secret") + var got system.ManifestPayload + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode manifest: %v", err) + } + + if !slices.Equal(got.Exposes.Tables, []string{"invoices", "orders"}) { + t.Errorf("exposes.tables = %v, want sorted [invoices orders]", got.Exposes.Tables) + } +} + +func TestExposeTable_TopLevelFacade(t *testing.T) { + resetDefault(t) + if err := Init(Config{ID: "media", Name: "Media"}); err != nil { + t.Fatalf("Init: %v", err) + } + + ExposeTable("orders") + ExposeTable("invoices") + + got := DefaultModule().registry.ExposedTables() + if !slices.Equal(got, []string{"invoices", "orders"}) { + t.Errorf("package-level ExposeTable -> ExposedTables() = %v, want sorted [invoices orders]", got) + } +} + +func TestExposeTable_TopLevelPanicsBeforeInit(t *testing.T) { + resetDefault(t) + assertPanics(t, "expected panic for top-level ExposeTable before Init", func() { + ExposeTable("orders") + }) +} diff --git a/internal/registry/exposure.go b/internal/registry/exposure.go new file mode 100644 index 0000000..7a1ac32 --- /dev/null +++ b/internal/registry/exposure.go @@ -0,0 +1,55 @@ +package registry + +import ( + "fmt" + "regexp" + "slices" +) + +// exposedTableNamePattern: Postgres-safe identifier. Lowercase, starts with a +// letter, only [a-z0-9_], up to 63 chars (the Postgres NAMEDATALEN ceiling). +// An exposed table lives under the module's `mod_` schema; the platform +// composes the fully-qualified name itself when it issues GRANT SELECT. +var exposedTableNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + +// AddExposedTable records a table NAME as eligible for SELECT by a depending +// module. v1 is TABLES ONLY, read-only — the producer marks a relation +// readable; it does NOT name WHO reads it. The app owner (not the producer) +// decides which installed modules may read by approving a dependency. There is +// intentionally no per-consumer allowlist on the exposure itself. +// +// Name must match exposedTableNamePattern; validation panics, like the rest of +// the registry — an invalid declaration is a programmer error caught at module +// init, not a runtime input. +// +// Dedup: declaring the same name twice is a no-op (set union). ExposedTables +// returns the de-duplicated set sorted, so repeated/feature-flagged +// declarations compose safely and the manifest output is deterministic. +func (r *Registry) AddExposedTable(name string) { + if !exposedTableNamePattern.MatchString(name) { + panic(fmt.Sprintf( + "mirrorstack/registry: ExposeTable(%q) name must be lowercase, start with a letter, only [a-z0-9_], <=63 chars", + name, + )) + } + r.mu.Lock() + defer r.mu.Unlock() + if slices.Contains(r.exposedTables, name) { + return + } + r.exposedTables = append(r.exposedTables, name) +} + +// ExposedTables returns a non-nil, SORTED, de-duplicated copy of all exposed +// table names. Sorting makes the manifest output deterministic (stable for +// prompt-cache and manifest-diffing) regardless of declaration order. +func (r *Registry) ExposedTables() []string { + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.exposedTables) == 0 { + return []string{} + } + out := slices.Clone(r.exposedTables) + slices.Sort(out) + return out +} diff --git a/internal/registry/exposure_test.go b/internal/registry/exposure_test.go new file mode 100644 index 0000000..b0ed371 --- /dev/null +++ b/internal/registry/exposure_test.go @@ -0,0 +1,123 @@ +package registry + +import ( + "slices" + "testing" +) + +func TestAddExposedTable_Records(t *testing.T) { + t.Parallel() + + r := New() + r.AddExposedTable("orders") + r.AddExposedTable("invoices") + + got := r.ExposedTables() + if !slices.Equal(got, []string{"invoices", "orders"}) { + t.Errorf("ExposedTables() = %v, want sorted [invoices orders]", got) + } +} + +func TestExposedTables_UnionDedup(t *testing.T) { + t.Parallel() + + // Re-declaring the same name (e.g. behind a feature flag) is a no-op: + // the set union keeps each name once. + r := New() + r.AddExposedTable("orders") + r.AddExposedTable("orders") + r.AddExposedTable("invoices") + + got := r.ExposedTables() + if len(got) != 2 { + t.Errorf("ExposedTables() = %v, want 2 distinct names", got) + } +} + +func TestExposedTables_SortedDeterministic(t *testing.T) { + t.Parallel() + + // Declaration order must NOT affect output order — the manifest must be + // stable for prompt-cache / manifest-diffing. Both orderings sort the same. + a := New() + a.AddExposedTable("zebra") + a.AddExposedTable("apple") + a.AddExposedTable("mango") + + b := New() + b.AddExposedTable("mango") + b.AddExposedTable("zebra") + b.AddExposedTable("apple") + + want := []string{"apple", "mango", "zebra"} + if got := a.ExposedTables(); !slices.Equal(got, want) { + t.Errorf("a.ExposedTables() = %v, want %v", got, want) + } + if got := b.ExposedTables(); !slices.Equal(got, want) { + t.Errorf("b.ExposedTables() = %v, want %v (order-independent)", got, want) + } +} + +func TestExposedTables_EmptyReturnsNonNil(t *testing.T) { + t.Parallel() + if got := New().ExposedTables(); got == nil { + t.Error("empty ExposedTables() returned nil, want []string{}") + } +} + +func TestExposedTables_ReturnsCopy(t *testing.T) { + t.Parallel() + + r := New() + r.AddExposedTable("orders") + + first := r.ExposedTables() + first[0] = "tampered" + + if second := r.ExposedTables(); second[0] != "orders" { + t.Errorf("ExposedTables() returned shared backing slice: caller mutation leaked, got %v", second) + } +} + +func TestAddExposedTable_PanicsOnInvalidName(t *testing.T) { + t.Parallel() + + bad := []string{ + "", // empty + "Orders", // uppercase + "1orders", // leading digit + "my orders", // whitespace + "orders-table", // hyphen (not a Postgres-safe bare identifier) + "mod.orders", // dot / schema-qualified + "../etc", // path traversal shape + } + for _, name := range bad { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + defer func() { + if rec := recover(); rec == nil { + t.Errorf("AddExposedTable(%q) did not panic, want panic on invalid identifier", name) + } + }() + New().AddExposedTable(name) + }) + } +} + +func TestAddExposedTable_AcceptsValidNames(t *testing.T) { + t.Parallel() + + for _, name := range []string{"orders", "order_items", "o", "a1", "user_2fa_tokens"} { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + defer func() { + if rec := recover(); rec != nil { + t.Errorf("AddExposedTable(%q) panicked, want accept: %v", name, rec) + } + }() + New().AddExposedTable(name) + }) + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index febd497..6ef44a6 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -182,6 +182,7 @@ type Registry struct { tasks []Task permissions []Permission metrics []MetricDecl + exposedTables []string description string dependencies []Dependency outboundContributions []OutboundContribution diff --git a/mirrorstack.go b/mirrorstack.go index d854096..da67b7f 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -357,6 +357,21 @@ func Resolve[T any](id string) (T, bool) { return core.Resolve[T](id) } // after app-owner approval. Pair with ms.DependsOn(host). See core.ContributesTo. func ContributesTo(host, slot string, payload any) { core.ContributesTo(host, slot, payload) } +// ExposeTable marks a table in this module's schema as read-only +// SELECT-eligible for a depending module — the producer side of DependsOn's +// n.Table. It is a zero-runtime DECLARATION that lands in the manifest under +// exposes.tables; the platform issues GRANT SELECT after the app owner +// approves a dependency. +// +// The producer only opts a table IN to being readable — it does NOT decide +// WHO reads it. The app owner (not the producer) is the trust root and +// chooses which installed modules may read by approving their dependency. +// There is intentionally NO consumer allowlist: in a marketplace the +// consumers are third parties, so a publisher-controlled reader list is the +// wrong model. v1 is TABLES ONLY, read-only (SELECT). Panics on an empty or +// invalid table identifier. Call from startup code. +func ExposeTable(name string) { core.ExposeTable(name) } + // --- UI surface --- // ModuleUI is the module's declared UI surface. Pass to ms.RegisterUI. diff --git a/system/manifest.go b/system/manifest.go index f417509..6c45e07 100644 --- a/system/manifest.go +++ b/system/manifest.go @@ -29,6 +29,12 @@ type ManifestPayload struct { Versions map[string]MigrationVersions `json:"versions"` Routes map[registry.Scope][]registry.Route `json:"routes"` Events ManifestEvents `json:"events"` + // Exposes lists the tables this module marks readable (SELECT-eligible) + // by a depending module (ms.ExposeTable). The platform catalog issues + // GRANT SELECT against the depending module's DB role after the app + // owner approves the dependency. Always present; tables is an empty + // array when nothing is exposed. + Exposes ManifestExposes `json:"exposes"` Schedules []registry.Schedule `json:"schedules"` Tasks []registry.Task `json:"tasks"` Permissions []registry.Permission `json:"permissions"` @@ -94,6 +100,16 @@ type ManifestEvents struct { Subscribes map[string]string `json:"subscribes"` } +// ManifestExposes declares the read-only (SELECT) surface this module opens to +// depending modules. Tables is a flat, sorted, de-duplicated list of table +// NAMES (ms.ExposeTable) in the module's `mod_` schema. There is no +// per-consumer "readableBy" list: the producer only marks a table +// SELECT-eligible; the app owner decides WHO reads it by approving a +// dependency. v1 is TABLES ONLY. +type ManifestExposes struct { + Tables []string `json:"tables"` +} + // buildManifestMCP projects the registry's MCP declarations into wire-safe // entries (Handler stripped). Uses the shared toolEntries/resourceEntries // helpers from mcp.go so list endpoints and manifest stay in lockstep. @@ -154,6 +170,7 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve Versions: versions, Routes: reg.Routes(), Events: ManifestEvents{Emits: reg.Emits(), Subscribes: reg.Subscribes()}, + Exposes: ManifestExposes{Tables: reg.ExposedTables()}, Schedules: reg.Schedules(), Tasks: reg.Tasks(), Permissions: reg.Permissions(), diff --git a/system/manifest_test.go b/system/manifest_test.go index a4f20db..b0a55c3 100644 --- a/system/manifest_test.go +++ b/system/manifest_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "strings" "testing" "testing/fstest" @@ -131,13 +132,37 @@ func TestManifest_EmptyEventsAndSchedules_NotNull(t *testing.T) { // Note: "module" is omitempty on MigrationVersions and VersionEntry, so // the empty manifest emits `"migration":{"app":""}` rather than // `{"app":"","module":""}`. The "app" field is always present. - for _, want := range []string{`"emits":[]`, `"subscribes":{}`, `"schedules":[]`, `"versions":{}`, `"permissions":[]`, `"migration":{"app":""}`} { + for _, want := range []string{`"emits":[]`, `"subscribes":{}`, `"schedules":[]`, `"versions":{}`, `"permissions":[]`, `"migration":{"app":""}`, `"exposes":{"tables":[]}`} { if !strings.Contains(body, want) { t.Errorf("manifest body missing %q\nbody: %s", want, body) } } } +func TestManifest_Exposes(t *testing.T) { + t.Parallel() + + reg := registry.New() + // Declare out of order — the manifest must emit a sorted, de-duplicated list. + reg.AddExposedTable("orders") + reg.AddExposedTable("invoices") + reg.AddExposedTable("orders") // dup, dropped + + got := decodeManifest(t, ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, reg, nil)) + + if !slices.Equal(got.Exposes.Tables, []string{"invoices", "orders"}) { + t.Errorf("exposes.tables = %v, want sorted [invoices orders]", got.Exposes.Tables) + } + + // Wire shape must be exactly {"exposes":{"tables":["invoices","orders"]}}. + req := httptest.NewRequest("GET", "/__mirrorstack/platform/manifest", nil) + rec := httptest.NewRecorder() + ManifestHandler("media", "", "Media", "perm_media", nil, nil, nil, reg, nil).ServeHTTP(rec, req) + if want := `"exposes":{"tables":["invoices","orders"]}`; !strings.Contains(rec.Body.String(), want) { + t.Errorf("manifest body missing %q\nbody: %s", want, rec.Body.String()) + } +} + func TestManifest_MigrationVersions(t *testing.T) { t.Parallel()