fix(billing): enforce enso per-tier gate — inject co-resident commerce tier into ai

The embedded ai per-tier SKU gate (family_tier.go) was fail-open in-cluster: it
resolved the caller's tier with an authed HTTP self-call to the cloud edge, which
401/403s a service token on /v1/billing/*, so the gate saw "" and admitted every
tier — enso/enso-ultra were open to free callers.

Mirror wireFinance's SetBalanceReader: install aiobject.SetTierReader so ai reads
the subscription tier DIRECTLY over the co-resident commerce client the metering
gate already bills over (commerceinproc in-process, with the service token commerce
itself accepts) — never the cloud edge. Add metering.Client.Tier to decode tier.name
from GET /v1/billing/tier. Fail-safe preserved: a commerce error or unknown tier
folds to "" (allow), so a commerce blip never locks out a paying caller.

Bumps ai v1.824.2 -> v1.825.2 (the object.TierReader seam).
This commit is contained in:
2026-07-18 16:54:47 -07:00
parent 932e1f6f32
commit 169beabdc2
5 changed files with 121 additions and 3 deletions
+23
View File
@@ -90,6 +90,7 @@ func BuildDeps(cfg *Config) Deps {
// commerce URL yields a !Enabled() client, so the wrap is a transparent
// pass-through and a dev deployment is never blocked.
deps.Metering = buildMeteringClient(cfg, logger)
wireTierReader(deps.Metering, logger)
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
wireFinance(cfg, logger)
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
@@ -190,6 +191,28 @@ func boolStr(b bool, t, f string) string {
return f
}
// wireTierReader installs the embedded ai module's per-tier SKU gate reader so it
// resolves the caller's commerce subscription tier through the SAME co-resident
// commerce client the metering gate bills over — in-process (commerceinproc) when
// commerce is folded in, S2S HTTP with the service token otherwise — NEVER an authed
// self-call to the cloud edge. That self-call is the toothless-gate bug: the edge
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP lookup always
// returned "" in-cluster and every tier-gated SKU failed OPEN. This mirrors
// wireFinance's SetBalanceReader: cloud owns the co-resident read, ai stays
// transport-agnostic. Fail-safe is preserved — Client.Tier folds a commerce error or
// an unknown plan to "", which the gate treats as ALLOW, so a commerce blip never
// locks out a paying caller. No-op when commerce is unreachable (metering !Enabled),
// leaving ai's standalone HTTP fallback in place.
func wireTierReader(m *metering.Client, log luxlog.Logger) {
if m == nil || !m.Enabled() {
return
}
aiobject.SetTierReader(func(ctx context.Context, subject, namespace string) (string, error) {
return m.Tier(ctx, subject, namespace)
})
log.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
}
// wireFinance constructs the ONE in-process finance ledger (per-org SQLite
// double-entry prepaid wallet), publishes it for every money consumer to resolve by
// the narrow finance.Client, and installs the embedded ai router's balance-read +
+33
View File
@@ -443,6 +443,39 @@ func (c *Client) fetchAvailable(ctx context.Context, user, org, cur string) (int
return br.Available, nil
}
// Tier resolves the subject's commerce subscription-plan NAME
// (free | starter | pro | enterprise) via GET /v1/billing/tier?user=<subject>,
// scoped to org (X-Org-Id). This is the in-process (co-resident) — or S2S HTTP —
// read the embedded ai module's per-tier SKU gate consumes (via
// aiobject.SetTierReader) INSTEAD of an authed self-call to the cloud edge: the edge
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP path always
// returned "" in-cluster and the gate failed OPEN. This rides the SAME transport and
// service token the metering gate already bills over, so it reaches commerce's OWN
// service-token middleware (which reads the tenant from X-Org-Id), never the cloud edge.
//
// Empty subject or a not-configured client returns ("", nil): the gate treats an
// unknown tier as ALLOW (fail-safe), so a commerce hiccup never locks out a paying
// caller. Unlike fetchAvailable this does NOT short-circuit to the finance ledger —
// the plan tier is a commerce subscription fact, not a wallet balance.
func (c *Client) Tier(ctx context.Context, subject, org string) (string, error) {
if !c.Enabled() || strings.TrimSpace(subject) == "" {
return "", nil
}
body, err := c.get(ctx, pathTier, url.Values{"user": {subject}}, c.orgFor(org))
if err != nil {
return "", err
}
var tr struct {
Tier struct {
Name string `json:"name"`
} `json:"tier"`
}
if err := json.Unmarshal(body, &tr); err != nil {
return "", fmt.Errorf("metering: decode tier name: %w", err)
}
return strings.TrimSpace(tr.Tier.Name), nil
}
// Usage is one usage event to record. The amount (the cost to debit) is the
// essential beside the billing key (User); the rest is descriptive metadata
// commerce stores on the transaction.
+62
View File
@@ -386,3 +386,65 @@ func TestContractMatchesGateway(t *testing.T) {
t.Errorf("URL %q must carry currency=usd", gotURL)
}
}
// Tier is the plan-NAME read the embedded ai per-tier SKU gate consumes over the
// co-resident commerce transport (aiobject.SetTierReader) — the fix for the toothless
// gate. It must GET /v1/billing/tier?user=<subject> with the service token + X-Org-Id
// (commerce's own middleware, never the cloud edge) and decode tier.name.
func TestTier_ResolvesPlanName(t *testing.T) {
fc := &fakeCommerce{reply: `{"user":"hanzo/alice","tier":{"name":"pro","displayName":"Pro"},"balance":{"effectiveAvailable":5000}}`}
srv := httptest.NewServer(fc.handler())
defer srv.Close()
c := newClient(t, srv, metering.Config{})
name, err := c.Tier(context.Background(), "hanzo/alice", "hanzo")
if err != nil {
t.Fatalf("Tier: %v", err)
}
if name != "pro" {
t.Errorf("tier name = %q, want pro", name)
}
if fc.method != http.MethodGet || fc.path != "/v1/billing/tier" {
t.Errorf("request = %s %s, want GET /v1/billing/tier", fc.method, fc.path)
}
if got := fc.query.Get("user"); got != "hanzo/alice" {
t.Errorf("user query = %q, want hanzo/alice", got)
}
if fc.auth != "Bearer svc-token" {
t.Errorf("auth = %q, want Bearer svc-token", fc.auth)
}
if fc.org != "hanzo" {
t.Errorf("X-Org-Id = %q, want hanzo", fc.org)
}
}
// An empty subject resolves to ("", nil) without touching commerce — the ai gate reads
// "" as unknown → ALLOW (fail-safe), so there is nothing to ask.
func TestTier_EmptySubject_NoCall(t *testing.T) {
fc := &fakeCommerce{reply: `{"tier":{"name":"pro"}}`}
srv := httptest.NewServer(fc.handler())
defer srv.Close()
c := newClient(t, srv, metering.Config{})
name, err := c.Tier(context.Background(), " ", "hanzo")
if err != nil || name != "" {
t.Fatalf("empty subject: got (%q,%v), want (\"\",nil)", name, err)
}
if fc.path != "" {
t.Errorf("empty subject must not call commerce, hit %s", fc.path)
}
}
// A commerce error SURFACES from Tier (it is not swallowed here). The fail-safe lives
// one layer up: the ai reader folds any error to "" → ALLOW, so a commerce blip never
// locks a paying caller out of a SKU. Proving the error propagates keeps that contract honest.
func TestTier_PropagatesCommerceError(t *testing.T) {
fc := &fakeCommerce{status: 500, reply: `boom`}
srv := httptest.NewServer(fc.handler())
defer srv.Close()
c := newClient(t, srv, metering.Config{})
if _, err := c.Tier(context.Background(), "hanzo/alice", "hanzo"); err == nil {
t.Fatal("commerce 500 must surface as error (the ai reader folds it to allow)")
}
}
+1 -1
View File
@@ -841,7 +841,7 @@ require (
github.com/hanzo-ds/go v1.0.1
github.com/hanzo-ds/native v0.72.0 // indirect
github.com/hanzoai/agent v0.1.3
github.com/hanzoai/ai v1.825.1
github.com/hanzoai/ai v1.825.2
github.com/hanzoai/authz v1.10.7
github.com/hanzoai/base v1.5.7
github.com/hanzoai/licensing v0.1.5
+2 -2
View File
@@ -1212,8 +1212,8 @@ github.com/hanzoai/account v0.2.0 h1:WxIut3YMz8JNdHerlRIqP1a+k3P79BIKM1mjfRFDG0Y
github.com/hanzoai/account v0.2.0/go.mod h1:8OzIGRphAhlabOI74O4GoL3RM0y8mbUV0pQUKgXLjkw=
github.com/hanzoai/agent v0.1.3 h1:zzV4t8kN/m/wTLrqzEy0fxxONSZbx3XSVH7TIR9gZNU=
github.com/hanzoai/agent v0.1.3/go.mod h1:Z3hCBdSeN/nGV4o+3F4psQ2bbFk17+tMP5l+G2ssNNA=
github.com/hanzoai/ai v1.825.1 h1:Ygid3bduFBlU3h/yKKwE3l9lm2LFrdAbN0K41wUm8g0=
github.com/hanzoai/ai v1.825.1/go.mod h1:LkSrjXJjFS9weIQmhXl53x/Dmt90KgkulEkx8O1Gd5U=
github.com/hanzoai/ai v1.825.2 h1:860nsBJGyUISgpdzWH1+Tu7yULEaoY9JQxwvEiU3g2s=
github.com/hanzoai/ai v1.825.2/go.mod h1:LkSrjXJjFS9weIQmhXl53x/Dmt90KgkulEkx8O1Gd5U=
github.com/hanzoai/authz v1.10.7 h1:JrHljH29mbmVi8u6/6EVG7R0NiFhIYYm2WUBBuBmFq0=
github.com/hanzoai/authz v1.10.7/go.mod h1:9wf6n6BvrvxRULUtL3yc+vmprwwvAGZLGJoMHhiQSK4=
github.com/hanzoai/authzstore v0.1.1 h1:4GsvB+bKs+gFtfKDMoYq/C7KxJAHrXLwtwdSutTakbo=