diff --git a/internal/core/description_i18n_test.go b/internal/core/description_i18n_test.go new file mode 100644 index 0000000..5dc594d --- /dev/null +++ b/internal/core/description_i18n_test.go @@ -0,0 +1,97 @@ +package core + +import ( + "encoding/json" + "testing" + "testing/fstest" + + "github.com/mirrorstack-ai/app-module-sdk/i18n" + "github.com/mirrorstack-ai/app-module-sdk/system" +) + +// newModuleWithSecret builds a module after setting MS_INTERNAL_SECRET, so its +// /platform/manifest route (behind internalAuth) is reachable with the "secret" +// header. Unlike newTestModuleWithSecret it takes a full Config so a test can +// declare Description / DescriptionLabel. +func newModuleWithSecret(t *testing.T, cfg Config) *Module { + t.Helper() + t.Setenv("MS_INTERNAL_SECRET", "secret") + m, err := New(cfg) + if err != nil { + t.Fatalf("New(%q): %v", cfg.ID, err) + } + return m +} + +// manifestOf serves the module's platform manifest and decodes it. Mirrors the +// meter-label tests: the description-i18n path is asserted end-to-end through +// the real manifest endpoint, not the registry directly. +func manifestOf(t *testing.T, m *Module) system.ManifestPayload { + t.Helper() + 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) + } + return got +} + +// TestDescriptionLabel_LiteralSurfacesInManifest asserts a Config.DescriptionLabel +// built from a literal folds a per-locale descriptionLabels map into the manifest +// (mirroring the permission/metric label path: a literal resolves under the +// default locale), while the plain Description string stays set as the fallback. +func TestDescriptionLabel_LiteralSurfacesInManifest(t *testing.T) { + m := newModuleWithSecret(t, Config{ID: "media", Description: "A demo module", DescriptionLabel: Text("A demo module")}) + got := manifestOf(t, m) + if got.DescriptionLabels[i18n.DefaultLocale] != "A demo module" { + t.Errorf("DescriptionLabels = %v, want %s=A demo module", got.DescriptionLabels, i18n.DefaultLocale) + } + if got.Description != "A demo module" { + t.Errorf("Description = %q, want %q (plain string stays the fallback)", got.Description, "A demo module") + } +} + +// TestDescription_PlainLeavesLabelsAbsent asserts a plain-string Description (no +// DescriptionLabel) ships the description string but NO descriptionLabels key +// (omitempty), so the platform falls back to the string. This is the back-compat +// path — existing ms.Config.Description keeps working unchanged. +func TestDescription_PlainLeavesLabelsAbsent(t *testing.T) { + m := newModuleWithSecret(t, Config{ID: "media", Description: "A demo module"}) + got := manifestOf(t, m) + if got.DescriptionLabels != nil { + t.Errorf("DescriptionLabels = %v, want nil (no label declared)", got.DescriptionLabels) + } + if got.Description != "A demo module" { + t.Errorf("Description = %q, want %q", got.Description, "A demo module") + } +} + +// TestDescriptionLabel_ResolvesCatalogPerLocale asserts a DescriptionLabel built +// from an i18n catalog key (ms.T) resolves to every loaded locale at manifest +// build, exactly like a permission/metric Label — and, because resolution is +// lazy, it works even though RegisterMessages here runs AFTER New (the module's +// real Init → RegisterMessages ordering). +func TestDescriptionLabel_ResolvesCatalogPerLocale(t *testing.T) { + i18n.Reset() + t.Cleanup(i18n.Reset) + + m := newModuleWithSecret(t, Config{ID: "media", Description: "Manages sign-in", DescriptionLabel: T("description")}) + + // Catalog registered AFTER New — lazy manifest-build resolution still picks + // it up, so authors are free to load catalogs after Init. + fsys := fstest.MapFS{ + "i18n/en-US.json": &fstest.MapFile{Data: []byte(`{"description":"Manages sign-in"}`)}, + "i18n/zh-TW.json": &fstest.MapFile{Data: []byte(`{"description":"管理登入"}`)}, + } + if err := i18n.RegisterMessages(fsys, "i18n"); err != nil { + t.Fatalf("RegisterMessages: %v", err) + } + + got := manifestOf(t, m) + if got.DescriptionLabels["en-US"] != "Manages sign-in" { + t.Errorf("en-US = %q, want %q", got.DescriptionLabels["en-US"], "Manages sign-in") + } + if got.DescriptionLabels["zh-TW"] != "管理登入" { + t.Errorf("zh-TW = %q, want %q", got.DescriptionLabels["zh-TW"], "管理登入") + } +} diff --git a/internal/core/meter_test.go b/internal/core/meter_test.go index ca9dc2f..c9e0936 100644 --- a/internal/core/meter_test.go +++ b/internal/core/meter_test.go @@ -6,8 +6,10 @@ import ( "net/http" "net/http/httptest" "testing" + "testing/fstest" "github.com/mirrorstack-ai/app-module-sdk/auth" + "github.com/mirrorstack-ai/app-module-sdk/i18n" "github.com/mirrorstack-ai/app-module-sdk/meter" "github.com/mirrorstack-ai/app-module-sdk/system" ) @@ -175,3 +177,144 @@ func TestRecord_RejectsUndeclaredName(t *testing.T) { t.Error("expected an error recording an undeclared metric name") } } + +// TestMeter_LabelSurfacesInManifest asserts that a metric declared with a +// literal MetricLabel folds a per-locale labels map into the manifest metric +// entry (mirroring the permission-label path: a literal resolves under the +// default locale), while a metric declared WITHOUT a label ships no labels key +// at all (omitempty). +func TestMeter_LabelSurfacesInManifest(t *testing.T) { + m := newTestModuleWithSecret(t, "media") + m.Meter("orders.placed", meter.Counter, meter.MetricLabel(Text("Orders placed"))) + m.Meter("sign.ins", meter.Counter) // no label + + 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 len(got.Metrics) != 2 { + t.Fatalf("metrics = %d, want 2: %+v", len(got.Metrics), got.Metrics) + } + for _, d := range got.Metrics { + switch d.Name { + case "orders.placed": + if d.Labels[i18n.DefaultLocale] != "Orders placed" { + t.Errorf("orders.placed labels = %v, want %s=Orders placed", d.Labels, i18n.DefaultLocale) + } + case "sign.ins": + if d.Labels != nil { + t.Errorf("sign.ins labels = %v, want nil (no label declared)", d.Labels) + } + default: + t.Errorf("unexpected metric %q", d.Name) + } + } +} + +// TestMeter_LabelResolvesCatalogPerLocale asserts a MetricLabel built from an +// i18n catalog key (ms.T) resolves to every loaded locale at manifest build, +// exactly like a permission Label — the catalog is registered via +// RegisterMessages before the manifest is served. +func TestMeter_LabelResolvesCatalogPerLocale(t *testing.T) { + i18n.Reset() + t.Cleanup(i18n.Reset) + fsys := fstest.MapFS{ + "i18n/en-US.json": &fstest.MapFile{Data: []byte(`{"metrics":{"orders.placed":"Orders placed"}}`)}, + "i18n/zh-TW.json": &fstest.MapFile{Data: []byte(`{"metrics":{"orders.placed":"訂單數"}}`)}, + } + if err := i18n.RegisterMessages(fsys, "i18n"); err != nil { + t.Fatalf("RegisterMessages: %v", err) + } + + m := newTestModuleWithSecret(t, "media") + m.Meter("orders.placed", meter.Counter, meter.MetricLabel(T("metrics.orders.placed"))) + + 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 len(got.Metrics) != 1 { + t.Fatalf("metrics = %d, want 1: %+v", len(got.Metrics), got.Metrics) + } + d := got.Metrics[0] + if d.Labels["en-US"] != "Orders placed" { + t.Errorf("en-US label = %q, want %q", d.Labels["en-US"], "Orders placed") + } + if d.Labels["zh-TW"] != "訂單數" { + t.Errorf("zh-TW label = %q, want %q", d.Labels["zh-TW"], "訂單數") + } +} + +// TestMeter_UnitLabelSurfacesInManifest asserts that a metric declared with a +// literal MetricUnitLabel folds a per-locale unitLabels map into the manifest +// metric entry (mirroring MetricLabel: a literal resolves under the default +// locale), while a metric declared WITHOUT a unit label ships no unitLabels key +// at all (omitempty). The raw Unit is unaffected either way. +func TestMeter_UnitLabelSurfacesInManifest(t *testing.T) { + m := newTestModuleWithSecret(t, "media") + m.Meter("orders.placed", meter.Counter, meter.Unit("order"), meter.MetricUnitLabel(Text("orders"))) + m.Meter("sign.ins", meter.Counter, meter.Unit("signin")) // no unit label + + 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 len(got.Metrics) != 2 { + t.Fatalf("metrics = %d, want 2: %+v", len(got.Metrics), got.Metrics) + } + for _, d := range got.Metrics { + switch d.Name { + case "orders.placed": + if d.UnitLabels[i18n.DefaultLocale] != "orders" { + t.Errorf("orders.placed unitLabels = %v, want %s=orders", d.UnitLabels, i18n.DefaultLocale) + } + if d.Unit != "order" { + t.Errorf("orders.placed unit = %q, want order (unaffected by unit label)", d.Unit) + } + case "sign.ins": + if d.UnitLabels != nil { + t.Errorf("sign.ins unitLabels = %v, want nil (no unit label declared)", d.UnitLabels) + } + default: + t.Errorf("unexpected metric %q", d.Name) + } + } +} + +// TestMeter_UnitLabelResolvesCatalogPerLocale asserts a MetricUnitLabel built +// from an i18n catalog key (ms.T) resolves to every loaded locale at manifest +// build, exactly like a MetricLabel — the catalog is registered via +// RegisterMessages before the manifest is served. +func TestMeter_UnitLabelResolvesCatalogPerLocale(t *testing.T) { + i18n.Reset() + t.Cleanup(i18n.Reset) + fsys := fstest.MapFS{ + "i18n/en-US.json": &fstest.MapFile{Data: []byte(`{"units":{"order":"orders"}}`)}, + "i18n/zh-TW.json": &fstest.MapFile{Data: []byte(`{"units":{"order":"訂單"}}`)}, + } + if err := i18n.RegisterMessages(fsys, "i18n"); err != nil { + t.Fatalf("RegisterMessages: %v", err) + } + + m := newTestModuleWithSecret(t, "media") + m.Meter("orders.placed", meter.Counter, meter.Unit("order"), meter.MetricUnitLabel(T("units.order"))) + + 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 len(got.Metrics) != 1 { + t.Fatalf("metrics = %d, want 1: %+v", len(got.Metrics), got.Metrics) + } + d := got.Metrics[0] + if d.UnitLabels["en-US"] != "orders" { + t.Errorf("en-US unit label = %q, want %q", d.UnitLabels["en-US"], "orders") + } + if d.UnitLabels["zh-TW"] != "訂單" { + t.Errorf("zh-TW unit label = %q, want %q", d.UnitLabels["zh-TW"], "訂單") + } +} diff --git a/internal/core/module.go b/internal/core/module.go index fb02011..886d2a4 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -54,6 +54,16 @@ type Config struct { // Description is a short, plain-language summary shown in the catalog/agent discovery. Optional. Description string + // DescriptionLabel is the optional per-locale form of Description, built from + // ms.Text (a literal) or ms.T (an i18n catalog key). When set it is resolved + // against the module's i18n catalogs at manifest build and folded into the + // manifest as descriptionLabels (locale → text) ALONGSIDE Description, so the + // platform can show a localized summary. Description stays the default / + // fallback; a zero Label is omitted entirely. Mirrors PermissionOpts.Description + // / meter.MetricLabel. Resolution is lazy (manifest build), so RegisterMessages + // may run before or after Init — only both-before-serve matters. + DescriptionLabel Label + // Tags are module-level category badges (e.g. "Auth", "Payments") shown in // the platform's module catalog / settings. Surfaced via manifest defaults. Tags []string @@ -289,6 +299,13 @@ func New(cfg Config) (*Module, error) { if cfg.Description != "" { m.registry.SetDescription(cfg.Description) } + // A DescriptionLabel rides ALONGSIDE the plain Description as a per-locale + // map in the manifest (mirroring permission/metric labels). Stored opaque + // and resolved lazily at manifest build, so catalog-load ordering is free. + // Skip a zero Label so plain-string modules ship no descriptionLabels key. + if !cfg.DescriptionLabel.IsZero() { + m.registry.SetDescriptionLabel(cfg.DescriptionLabel) + } m.mountSystemRoutes() return m, nil diff --git a/internal/core/resources.go b/internal/core/resources.go index b03f713..5aea8d9 100644 --- a/internal/core/resources.go +++ b/internal/core/resources.go @@ -109,6 +109,12 @@ func (m *Module) Meter(name string, opts ...meter.MetricOption) { p := d.Price decl.Price = &p } + if !d.Label.IsZero() { + decl.Labels = d.Label.Resolve() + } + if !d.UnitLabel.IsZero() { + decl.UnitLabels = d.UnitLabel.Resolve() + } m.registry.AddMetric(decl) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 6ef44a6..1b9211f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -15,6 +15,8 @@ import ( "maps" "slices" "sync" + + "github.com/mirrorstack-ai/app-module-sdk/i18n" ) // Scope identifies which auth boundary a route belongs to. The three values @@ -134,11 +136,22 @@ type OutboundContribution struct { // Price is the per-unit CUSTOMER price in micro-dollars and is omitempty: a // metric may be metered without a declared price (PriceSet distinguishes a // declared 0 from "no price", carried via the pointer being non-nil). +// Labels are per-locale display strings (locale → text), resolved at +// registration from the module's i18n catalogs (mirrors Permission.Labels). +// omitempty: a metric that declares no label ships no key and the platform +// falls back to the raw metric name. +// UnitLabels are per-locale display strings for the metric's Unit (locale → +// text), resolved the same way (mirrors Labels). omitempty: a metric that +// declares no unit label ships no key and the platform falls back to the raw +// Unit identifier. Distinct from Unit, which stays the untranslated billing +// unit used for per-unit pricing aggregation. type MetricDecl struct { - Name string `json:"name"` - Kind string `json:"kind,omitempty"` - Unit string `json:"unit,omitempty"` - Price *int64 `json:"price,omitempty"` + Name string `json:"name"` + Kind string `json:"kind,omitempty"` + Unit string `json:"unit,omitempty"` + Price *int64 `json:"price,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + UnitLabels map[string]string `json:"unitLabels,omitempty"` } // MCPToolHandler is the type-erased handler signature used after generic @@ -184,6 +197,7 @@ type Registry struct { metrics []MetricDecl exposedTables []string description string + descriptionLabel i18n.Label // per-locale description (ms.T/ms.Text), resolved at manifest build dependencies []Dependency outboundContributions []OutboundContribution mcpTools []MCPToolDecl @@ -213,6 +227,36 @@ func (r *Registry) Description() string { return r.description } +// SetDescriptionLabel records the module's per-locale description Label (ms.Text +// / ms.T), typically set once via DescriptionLabel on ms.Config. The Label is +// stored OPAQUE and resolved LATER (at manifest build, via DescriptionLabels) — +// the same lazy timing as ManifestDefaults.NameLabels — so it does not matter +// whether RegisterMessages ran before or after the label was set, only that both +// ran before the manifest is served. The plain Description string stays the +// default/fallback; this rides ALONGSIDE it per locale. Last-write-wins. +func (r *Registry) SetDescriptionLabel(l i18n.Label) { + r.mu.Lock() + defer r.mu.Unlock() + r.descriptionLabel = l +} + +// DescriptionLabels resolves the module's per-locale description display strings +// (locale → text) against the catalogs loaded via RegisterMessages, or nil if no +// DescriptionLabel was declared. nil so the manifest omits the key (omitempty) +// and the platform falls back to Description. Mirrors Permission.Descriptions / +// MetricDecl.Labels — a resolved map keyed per locale. The Label is copied out +// under the lock and resolved AFTER releasing it, so Resolve's own i18n lock is +// never taken while holding the registry lock. +func (r *Registry) DescriptionLabels() map[string]string { + r.mu.RLock() + l := r.descriptionLabel + r.mu.RUnlock() + if l.IsZero() { + return nil + } + return l.Resolve() +} + // AddDependency records a dependency on another module. The Optional flag // distinguishes required (install-time) from optional (runtime Resolve) deps. // Version carries a SemVer constraint string (already validated by the @@ -562,6 +606,8 @@ func (r *Registry) AddMetric(d MetricDecl) bool { p := *d.Price d.Price = &p } + d.Labels = maps.Clone(d.Labels) + d.UnitLabels = maps.Clone(d.UnitLabels) r.metrics = append(r.metrics, d) return true } @@ -582,6 +628,8 @@ func (r *Registry) Metrics() []MetricDecl { p := *d.Price out[i].Price = &p } + out[i].Labels = maps.Clone(d.Labels) + out[i].UnitLabels = maps.Clone(d.UnitLabels) } return out } diff --git a/meter/meter.go b/meter/meter.go index 51c88fa..1c623f1 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -32,6 +32,7 @@ import ( "time" "github.com/mirrorstack-ai/app-module-sdk/auth" + "github.com/mirrorstack-ai/app-module-sdk/i18n" "github.com/mirrorstack-ai/app-module-sdk/internal/ids" ) @@ -98,13 +99,15 @@ type MetricOption interface { } type metricOptions struct { - kind Kind - kindSet bool - kindDup bool // a second, conflicting kind option was passed - unit string - unitSet bool - price int64 - priceSet bool + kind Kind + kindSet bool + kindDup bool // a second, conflicting kind option was passed + unit string + unitSet bool + price int64 + priceSet bool + label i18n.Label + unitLabel i18n.Label } // Counter / Gauge are CONSTANTS, not vars, so a third-party module cannot @@ -166,6 +169,33 @@ func Price(microDollars int64) MetricOption { }) } +// MetricLabel sets the metric's per-locale display label, built from ms.Text +// (a literal) or ms.T (an i18n catalog key). It is resolved against the +// module's i18n catalogs at manifest build (mirroring PermissionOpts.Label) and +// folded into the manifest metric entry per-locale, so the platform's pricing / +// usage UI can show a localized name instead of the raw metric key. Optional — a +// zero Label is omitted entirely. +// +// Named MetricLabel (not Label) because ms.Label is already the exported label +// TYPE; a top-level Label option would collide with it. +func MetricLabel(l i18n.Label) MetricOption { + return metricOptionFunc(func(o *metricOptions) { o.label = l }) +} + +// MetricUnitLabel sets the metric's per-locale display label for its UNIT, +// built from ms.Text (a literal) or ms.T (an i18n catalog key). It is resolved +// against the module's i18n catalogs at manifest build (mirroring MetricLabel) +// and folded into the manifest metric entry per-locale, so the platform's +// pricing / usage UI can show a localized unit (e.g. "使用者") instead of the raw +// Unit identifier. Optional — a zero Label is omitted entirely. +// +// The Unit itself (ms.Unit) stays the untranslated billing unit identifier used +// for per-unit pricing aggregation; this is a SEPARATE display-only label and +// does NOT change Unit's semantics. +func MetricUnitLabel(l i18n.Label) MetricOption { + return metricOptionFunc(func(o *metricOptions) { o.unitLabel = l }) +} + // DeclFromOptions applies the variadic options to produce a Decl. Used by core // (ms.Meter) to translate the public name + options into the declaration that // is both validated + registered (Declare) and registered into the manifest @@ -181,14 +211,16 @@ func DeclFromOptions(name string, opts ...MetricOption) Decl { } } return Decl{ - Name: name, - Kind: o.kind, - KindSet: o.kindSet, - kindDup: o.kindDup, - Unit: o.unit, - UnitSet: o.unitSet, - Price: o.price, - PriceSet: o.priceSet, + Name: name, + Kind: o.kind, + KindSet: o.kindSet, + kindDup: o.kindDup, + Unit: o.unit, + UnitSet: o.unitSet, + Price: o.price, + PriceSet: o.priceSet, + Label: o.label, + UnitLabel: o.unitLabel, } } @@ -214,6 +246,15 @@ type Decl struct { UnitSet bool Price int64 PriceSet bool + // Label is the metric's optional per-locale display label (ms.Text / ms.T), + // resolved into the manifest metric entry in core.Meter (mirroring the + // permission-label path). Zero when undeclared. + Label i18n.Label + // UnitLabel is the metric's optional per-locale display label for its Unit + // (ms.Text / ms.T), resolved into the manifest metric entry in core.Meter + // (mirroring Label). Zero when undeclared; does not affect Unit's billing + // semantics. + UnitLabel i18n.Label } // IsReserved reports whether name falls under a platform-owned namespace diff --git a/mirrorstack.go b/mirrorstack.go index 84995c4..a35dbd2 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -163,6 +163,18 @@ func Unit(u string) MetricOption { return meter.Unit(u) } // quantity × this price with NO blanket markup for a module's custom metric. func Price(microDollars int64) MetricOption { return meter.Price(microDollars) } +// MetricLabel sets a metric's per-locale display label, built from ms.Text +// (literal) or ms.T (i18n catalog key), resolved against the module's i18n +// catalogs at manifest build (mirrors PermissionOpts.Label). Optional. +func MetricLabel(l Label) MetricOption { return meter.MetricLabel(l) } + +// MetricUnitLabel sets a metric's per-locale display label for its UNIT, built +// from ms.Text (literal) or ms.T (i18n catalog key), resolved against the +// module's i18n catalogs at manifest build (mirrors MetricLabel). The Unit +// itself stays the untranslated billing identifier; this is a separate +// display-only label. Optional. +func MetricUnitLabel(l Label) MetricOption { return meter.MetricUnitLabel(l) } + // Meter DECLARES a usage metric on the default module. Call it ONCE per metric // in startup code (exactly like ms.Emits / ms.RegisterPermission) — it registers // the metric as a SIDE EFFECT and returns NOTHING. The declaration (kind + unit diff --git a/system/manifest.go b/system/manifest.go index 6c45e07..4b40b84 100644 --- a/system/manifest.go +++ b/system/manifest.go @@ -21,23 +21,28 @@ type ManifestPayload struct { // Slug is the catalog handle (e.g. "oauth"). Empty for dev/legacy // modules that haven't been assigned a slug yet — the platform falls // back to ID-based addressing in that case. - Slug string `json:"slug,omitempty"` - Defaults ManifestDefaults `json:"defaults"` - Description string `json:"description,omitempty"` - Dependencies []registry.Dependency `json:"dependencies"` - Migration MigrationVersions `json:"migration"` - Versions map[string]MigrationVersions `json:"versions"` - Routes map[registry.Scope][]registry.Route `json:"routes"` - Events ManifestEvents `json:"events"` + Slug string `json:"slug,omitempty"` + Defaults ManifestDefaults `json:"defaults"` + Description string `json:"description,omitempty"` + // DescriptionLabels carries per-locale description display strings (locale → + // text), resolved from the module's i18n catalog (ms.Config.DescriptionLabel). + // Omitted when the module declared none, in which case the platform falls + // back to Description. Mirrors Permission.Descriptions / MetricDecl.Labels. + DescriptionLabels map[string]string `json:"descriptionLabels,omitempty"` + Dependencies []registry.Dependency `json:"dependencies"` + Migration MigrationVersions `json:"migration"` + 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"` + Exposes ManifestExposes `json:"exposes"` + Schedules []registry.Schedule `json:"schedules"` + Tasks []registry.Task `json:"tasks"` + Permissions []registry.Permission `json:"permissions"` // Metrics lists the usage metrics this module declares (ms.Meter). The // platform populates its metric_definitions catalog (kind/unit/price) from // this at install/publish, so the catalog is authoritative before any usage @@ -161,24 +166,25 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve } httputil.JSON(w, http.StatusOK, ManifestPayload{ - ID: id, - Slug: slug, - Defaults: ManifestDefaults{Name: name, Icon: icon, Tags: tags, NameLabels: i18n.Lookup("module.name")}, - Description: reg.Description(), - Dependencies: reg.Dependencies(), - Migration: MigrationVersions{App: appVersion, Module: moduleVersion}, - 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(), - Metrics: reg.Metrics(), - MCP: buildManifestMCP(reg), - UI: reg.UI(), - Provides: contribSlots, - ContributesTo: reg.OutboundContributions(), + ID: id, + Slug: slug, + Defaults: ManifestDefaults{Name: name, Icon: icon, Tags: tags, NameLabels: i18n.Lookup("module.name")}, + Description: reg.Description(), + DescriptionLabels: reg.DescriptionLabels(), + Dependencies: reg.Dependencies(), + Migration: MigrationVersions{App: appVersion, Module: moduleVersion}, + 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(), + Metrics: reg.Metrics(), + MCP: buildManifestMCP(reg), + UI: reg.UI(), + Provides: contribSlots, + ContributesTo: reg.OutboundContributions(), }) } }