Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions internal/core/description_i18n_test.go
Original file line number Diff line number Diff line change
@@ -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"], "管理登入")
}
}
143 changes: 143 additions & 0 deletions internal/core/meter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"], "訂單")
}
}
17 changes: 17 additions & 0 deletions internal/core/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/core/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
56 changes: 52 additions & 4 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading
Loading