money: one spend predicate, and billable is not price
A stranger can self-signup and run inference we pay a provider for, at zero
balance. Not for want of auth — every gate on the path checks auth. The binary
shipped THREE paywalls and enforced none:
- routers.Paywall (mounted app-wide) asked only "does the org hold a paid
PLAN?". No credit leg, so turning it on would have 402'd every prepaid
customer. That is why it shipped dark and stayed dark.
- BillingGate (mounted app-wide) gates on price(path) > 0, and DefaultPrice
returns 0 for every path — it never evaluates anything.
- entitlements.RequireProduct had the right answer and was mounted on no
route at all.
The predicate lived in clients/entitlements, a leaf that imports the root — the
wrong side of the dependency, so the edge filters serve.go mounts could never
reach it and grew their own divergent copies instead. Move it to the one package
every gate can import and delete the copies.
BILLABLE IS NOT PRICE. Authorization and pricing were one int64. Every inference
path prices at 0 at the edge — deliberately, since ai and zen self-meter their
token costs — and `cents <= 0` was read as "do not gate", so "we charge nothing
HERE" silently meant "we authorize NOTHING here". The same fusion un-gates every
resource an operator prices at 0 (ResourceMeter.Gate: costCents <= 0 -> nil).
That fusion is why the LLM leak and the non-LLM gap are one bug. Two questions
now: Billable says whether standing is required, DefaultPrice says what the edge
charges. Pricing at zero can no longer un-authorize.
SpendGate replaces routers.Paywall at the same mount: subscription OR prepaid
credit (cloud.Stand), read at the wallet address the DEBIT writes, over the LLM
paths and the non-LLM resource trees alike.
DEFAULT OFF, and that is sequencing, not timidity. There is no reachable
starter-credit path in this binary: grant-starter was deleted from commerce
(last in v1.48.2), its replacement POST /v1/billing/credit is not registered in
the co-resident build, and no ensureStarterCredit runs anywhere. A new signup's
wallet is $0 with no self-service way to fund it, so enforcing today trades a
revenue leak for a total signup outage. Flip it only after a funding path exists.
Also fixes the kill switch, which could not kill. serve.go ORed a boot-time
cfg.PaywallEnforced on top of the cockpit switch, so an env var could arm a gate
the cockpit could not disarm. The flag's own Env fallback did the same. Both
gone — entitlements' TestSwitchesDefaultOff already asserted this and was RED on
main. PAYWALL_ENFORCED is set in no deployment and no CR, so this is inert in
production.
Unknown refuses AS UNKNOWN. Unpaid requires BOTH authorities to have answered
no; anything less is Unknown and takes the posture (paywall_strict, also default
off), never a fabricated delinquency. Reads, the pay path, inbound payment
webhooks, and SuperAdmin masquerade are never gated.
Tests: 16 gate cases, 22 Billable cases, the atto boundary, and the address
property every prior recurrence violated — gate the pool, spend the person's
wallet. Mutation-checked: pool-instead-of-wallet, Unknown-admits, and
gate-the-reads each turn them red.
This commit is contained in:
+37
-131
@@ -11,13 +11,17 @@ package entitlements
|
||||
// It applies a verdict; it does not compute one. WHAT the caller's standing is
|
||||
// lives in standing.go (subscription OR prepaid credit, resolved from the two
|
||||
// authorities). This file decides only WHETHER and HOW to refuse: the admin kill
|
||||
// switch, the invariant that the path to payment stays reachable, and the shape of
|
||||
// the 402. Policy is never restated here — the plan→product rule lives once, in
|
||||
// switch and the fail posture. WHAT the caller's standing is, the invariant that the
|
||||
// path to payment stays reachable (cloud.Reachable), and the shape of the 402
|
||||
// (cloud.Refuse) all live in the ROOT package now, because the edge filters serve.go
|
||||
// mounts could never import this leaf and so grew their own divergent copies. This
|
||||
// file is what is genuinely product-scoped and nothing more: the licence leg
|
||||
// (CheckEntitlement for one product) and its application. Policy is never restated
|
||||
// here — the plan→product rule lives once, in
|
||||
// @hanzo/plans, resolved by commerce.CheckEntitlement.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
@@ -55,15 +59,17 @@ const enforceKey = cloud.SwitchPaywallEnforced
|
||||
// refuses a customer. ON = revenue: an unresolvable standing refuses. It exists so
|
||||
// the choice between those two is made live, by an owner watching real traffic,
|
||||
// instead of being frozen into a constant at build time.
|
||||
const strictKey = "paywall_strict"
|
||||
const strictKey = cloud.SwitchPaywallStrict
|
||||
|
||||
func init() {
|
||||
flags.Register(flags.Def{
|
||||
Key: enforceKey, Category: "Launch", Type: flags.TypeBool, Default: "false",
|
||||
// PAYWALL_ENFORCED remains the fallback, so a deployment with no cockpit
|
||||
// write behaves exactly as it did before this switch governed anything.
|
||||
// The first write in admin.hanzo.ai takes over and hot-applies.
|
||||
Env: "PAYWALL_ENFORCED",
|
||||
// NO Env FALLBACK, deliberately, and TestSwitchesDefaultOff pins it. An env
|
||||
// var that can turn the gate ON cannot be turned OFF from the cockpit — the
|
||||
// kill switch would be defeated by the very variable that armed the gate,
|
||||
// which is the one failure mode this switch exists to prevent. serve.go used
|
||||
// to OR a PAYWALL_ENFORCED config field on top of the switch and had exactly
|
||||
// that hole; the field is gone. The cockpit is the single source of truth.
|
||||
Label: "Paywall enforced",
|
||||
Desc: "Refuse gated product routes for a caller with neither an active subscription nor prepaid credit. " +
|
||||
"OFF = dark: the gate admits everything and consults no billing authority. This is the kill switch — " +
|
||||
@@ -130,7 +136,7 @@ func RequireProduct(commerce cloud.CommerceClient, product string) zip.Handler {
|
||||
if !flags.Bool(enforceKey) {
|
||||
return c.Next() // dark — byte-identical to no middleware at all.
|
||||
}
|
||||
if reachable(c.Path()) {
|
||||
if cloud.Reachable(c.Path()) {
|
||||
return c.Next()
|
||||
}
|
||||
if !principal.Validated(c) {
|
||||
@@ -148,144 +154,44 @@ func RequireProduct(commerce cloud.CommerceClient, product string) zip.Handler {
|
||||
// org against the platform's own balance. A caller with no resolvable wallet
|
||||
// simply has no credit leg; the subscription leg still answers.
|
||||
w, _ := principal.WalletOf(c)
|
||||
switch s := Resolve(c.Context(), commerce, org, product, w); {
|
||||
switch s := cloud.Stand(c.Context(), licensedBy(c.Context(), commerce, org, product), w); {
|
||||
case s.Admits():
|
||||
return c.Next()
|
||||
case s == Unknown:
|
||||
case s == cloud.Unknown:
|
||||
if flags.Bool(strictKey) {
|
||||
c.Log().Warn("paywall: standing unresolvable; refusing (strict)", "product", product, "org", org)
|
||||
return refuse(c, product, reasonUnresolved)
|
||||
return cloud.Refuse(c, product, cloud.ReasonUnresolved)
|
||||
}
|
||||
c.Log().Warn("paywall: standing unresolvable; admitting", "product", product, "org", org)
|
||||
return c.Next()
|
||||
default:
|
||||
// The ONE proven refusal: no plan licenses the product AND the wallet is empty.
|
||||
c.Log().Info("paywall: no subscription and no credit", "product", product, "org", org, "standing", s.String())
|
||||
return refuse(c, product, reasonUnpaid)
|
||||
return cloud.Refuse(c, product, cloud.ReasonUnpaid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the refusal ─────────────────────────────────────────────────────────────────
|
||||
// ── the subscription leg ────────────────────────────────────────────────────────
|
||||
|
||||
// The two reasons a 402 can carry. `unpaid` is the proven refusal; `unresolved` is
|
||||
// the strict-posture refusal, and it is a DISTINCT code because the caller's cure is
|
||||
// different — an unpaid caller must buy something, an unresolved one must retry.
|
||||
const (
|
||||
reasonUnpaid = "unpaid"
|
||||
reasonUnresolved = "unresolved"
|
||||
)
|
||||
|
||||
// Refusal is the 402 body. A bare 402 is useless to the console shell, so this names
|
||||
// WHAT is gated, WHY, and every way to cure it — one Cure per admit leg, so the body
|
||||
// is the structural mirror of the gate and can never drift from it.
|
||||
// licensedBy asks the ONE entitlement authority whether org's plan licenses product,
|
||||
// and reports the answer in the shape the shared predicate composes. commerce may be
|
||||
// nil (not co-resident); a query error or a nil result with no error is machinery that
|
||||
// returned nothing, and machinery that returned nothing has NOT said "no" — all three
|
||||
// are cloud.LicenceUnknown, never cloud.LicenceNone.
|
||||
//
|
||||
// The cure paths are RELATIVE and same-origin, deliberately: a hard-coded
|
||||
// cloud.hanzo.ai would brand a Lux / Zoo / Pars deployment as Hanzo, and this binary
|
||||
// white-labels by host. They point at the two API surfaces the shell already reads
|
||||
// and that `reachable` guarantees are never gated — /v1/plans (what to buy, with
|
||||
// prices, from @hanzo/plans) and /v1/billing (where to pay, subscribe or top up).
|
||||
type Refusal struct {
|
||||
Error string `json:"error"` // stable machine code: always "payment_required"
|
||||
Product string `json:"product"` // the gated @hanzo/plans product id
|
||||
Reason string `json:"reason"` // "unpaid" | "unresolved"
|
||||
Message string `json:"message"` // one human sentence
|
||||
Cure []Cure `json:"cure"` // the ways to fix it, in the order to offer them
|
||||
}
|
||||
|
||||
// Cure is one way out of a 402: Kind names the admit leg it satisfies
|
||||
// ("subscribe" | "credit"), URL where to do it.
|
||||
type Cure struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// cures are the two ways to cure a refusal — exactly the two legs standing.Resolve
|
||||
// admits on, in the order the console should offer them (a subscription is the
|
||||
// steady state; prepay is the no-commitment path).
|
||||
var cures = []Cure{
|
||||
{Kind: "subscribe", URL: "/v1/plans"},
|
||||
{Kind: "credit", URL: "/v1/billing"},
|
||||
}
|
||||
|
||||
func refuse(c *zip.Ctx, product, reason string) error {
|
||||
msg := "this org has no active subscription for " + product + " and no prepaid credit"
|
||||
if reason == reasonUnresolved {
|
||||
msg = "billing could not be verified for " + product + "; retry shortly"
|
||||
// The plan->product policy lives ONCE, in @hanzo/plans, resolved by CheckEntitlement.
|
||||
// We read its verdict; we never restate it.
|
||||
func licensedBy(ctx context.Context, commerce cloud.CommerceClient, org, product string) cloud.Licence {
|
||||
if commerce == nil {
|
||||
return cloud.LicenceUnknown
|
||||
}
|
||||
return c.JSON(http.StatusPaymentRequired, Refusal{
|
||||
Error: "payment_required",
|
||||
Product: product,
|
||||
Reason: reason,
|
||||
Message: msg,
|
||||
Cure: cures,
|
||||
})
|
||||
}
|
||||
|
||||
// ── the invariant: the path to payment is never gated ───────────────────────────
|
||||
|
||||
// reachable reports whether a path must be served REGARDLESS of standing.
|
||||
//
|
||||
// This is not a scope selector — which routes this middleware guards is decided by
|
||||
// where it is applied. It is a SAFETY PROPERTY of the gate: a customer who has not
|
||||
// yet paid must still be able to reach the paths that let them pay, and a lapsed one
|
||||
// must be able to cure their own lapse. Gate those and you deadlock every prospect
|
||||
// and every lapsed customer at once — a self-inflicted total revenue stop that looks
|
||||
// like success in a naive test, because everything returns 402. So the list lives
|
||||
// INSIDE the gate: no future wiring mistake can gate the pay path, whatever it wraps.
|
||||
//
|
||||
// Gating too little is a revenue leak we fix next week. Gating too much is an outage.
|
||||
// The list is therefore deliberately generous, and anything ambiguous belongs on it.
|
||||
//
|
||||
// Traced from the console subscribe flow (console src/components/products/
|
||||
// PlansModule.tsx → src/lib/api/plans.ts): PlansApi.plans() reads /v1/billing/plans
|
||||
// through the per-tenant billing proxy, and checkout drives the /v1/billing/* money
|
||||
// surface (subscribe/subscriptions/balance/payment-methods/usage/invoices/credit/
|
||||
// deposit/topup/gpu/spend-alerts/payment-config) — which also carries the INBOUND
|
||||
// PROVIDER WEBHOOKS at /v1/billing/webhooks/:provider. Gating an inbound payment
|
||||
// webhook loses payments outright, so that prefix is load-bearing twice over.
|
||||
func reachable(path string) bool {
|
||||
// Liveness / readiness / metrics — a gate must never hide whether the process
|
||||
// is up, or an incident becomes invisible at exactly the wrong moment.
|
||||
switch path {
|
||||
case "/health", "/healthz", "/readyz", "/livez", "/metrics":
|
||||
return true
|
||||
ent, err := commerce.CheckEntitlement(ctx, org, product)
|
||||
if err != nil || ent == nil {
|
||||
return cloud.LicenceUnknown
|
||||
}
|
||||
if strings.HasSuffix(path, "/health") {
|
||||
return true // the per-subsystem HIP-0106 probe, /v1/<name>/health.
|
||||
if ent.Active {
|
||||
return cloud.LicenceActive
|
||||
}
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
return true // the SPA shell + static assets that render the paywall screen itself.
|
||||
}
|
||||
switch path {
|
||||
case "/v1/signin", // auth: session bootstrap (the console posts the OAuth code here).
|
||||
"/v1/signout",
|
||||
"/v1/get-account", // auth: the account read AuthGate loads before anything else.
|
||||
"/v1/entitlements": // this paywall's OWN projection — what the shell renders the upgrade UI from.
|
||||
return true
|
||||
}
|
||||
for _, sub := range reachableTrees {
|
||||
// A sub-tree covers its own ROOT as well as everything under it. Matching only
|
||||
// "<root>/" is how a paywall ends up refusing the exact URL its own 402 points
|
||||
// at — one missing slash and the cure is behind the gate. One rule, both forms.
|
||||
if path == strings.TrimSuffix(sub, "/") || strings.HasPrefix(path, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reachableTrees are the /v1 sub-trees the paywall may never refuse. Every entry is
|
||||
// written as a root WITH its trailing slash and covers the bare root too.
|
||||
var reachableTrees = []string{
|
||||
"/v1/billing/", // the whole money surface: subscribe, top up, AND the inbound provider webhooks.
|
||||
"/v1/iam/", // IAM login / OAuth token exchange / .well-known OIDC discovery.
|
||||
"/v1/account/", // the account surface the shell renders before any purchase decision.
|
||||
"/v1/admin/", // platform sudo — including the cockpit that holds this gate's kill switch.
|
||||
"/v1/plans/", // the plans catalog — WHAT to buy (@hanzo/plans) — and its sub-routes.
|
||||
"/v1/models/", // the model catalog the shell reads for discovery, and /v1/models/:id.
|
||||
"/v1/orgs/", // org read + switch: the shell must resolve which org it is buying for.
|
||||
"/v1/waitlist/", // admission's join API — an un-admitted user must still reach it.
|
||||
"/v1/flags/", // the guard's public mode read; also how the kill switch is observed.
|
||||
"/v1/entitlements/", // per-org enablement reads/writes that sit beside the projection.
|
||||
return cloud.LicenceNone
|
||||
}
|
||||
|
||||
@@ -150,14 +150,14 @@ func TestRequireProduct(t *testing.T) {
|
||||
{
|
||||
name: "neither → 402 unpaid",
|
||||
enforced: true, commerce: refusing, ledger: &fakeLedger{credit: atto(0)},
|
||||
want: http.StatusPaymentRequired, reason: reasonUnpaid,
|
||||
want: http.StatusPaymentRequired, reason: cloud.ReasonUnpaid,
|
||||
},
|
||||
|
||||
// EXACT atto boundary — no cents, no threshold, no rounding.
|
||||
{
|
||||
name: "balance == 0 atto denies",
|
||||
enforced: true, commerce: refusing, ledger: &fakeLedger{credit: atto(0)},
|
||||
want: http.StatusPaymentRequired, reason: reasonUnpaid,
|
||||
want: http.StatusPaymentRequired, reason: cloud.ReasonUnpaid,
|
||||
},
|
||||
{
|
||||
name: "balance == 1 atto admits (a 10^-18 USD credit is credit)",
|
||||
@@ -184,7 +184,7 @@ func TestRequireProduct(t *testing.T) {
|
||||
{
|
||||
name: "strict: unresolvable → 402 unresolved",
|
||||
enforced: true, strict: true, commerce: broken, ledger: nil,
|
||||
want: http.StatusPaymentRequired, reason: reasonUnresolved,
|
||||
want: http.StatusPaymentRequired, reason: cloud.ReasonUnresolved,
|
||||
},
|
||||
{
|
||||
name: "strict does NOT refuse a funded caller whose plan is unreadable",
|
||||
@@ -221,9 +221,9 @@ func boolText(b bool) string {
|
||||
return "false"
|
||||
}
|
||||
|
||||
func decodeRefusal(t *testing.T, body []byte) Refusal {
|
||||
func decodeRefusal(t *testing.T, body []byte) cloud.Refusal {
|
||||
t.Helper()
|
||||
var r Refusal
|
||||
var r cloud.Refusal
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatalf("decode Refusal: %v (body=%s)", err, body)
|
||||
}
|
||||
@@ -330,7 +330,7 @@ func TestRefusalIsActionable(t *testing.T) {
|
||||
t.Fatalf("want 402, got %d (body=%s)", code, body)
|
||||
}
|
||||
r := decodeRefusal(t, body)
|
||||
if r.Error != "payment_required" || r.Product != "world" || r.Reason != reasonUnpaid || r.Message == "" {
|
||||
if r.Error != "payment_required" || r.Product != "world" || r.Reason != cloud.ReasonUnpaid || r.Message == "" {
|
||||
t.Fatalf("refusal is not self-describing: %+v", r)
|
||||
}
|
||||
// One cure per admit leg — the body is the structural mirror of the gate.
|
||||
@@ -342,7 +342,7 @@ func TestRefusalIsActionable(t *testing.T) {
|
||||
if kinds[want] == "" {
|
||||
t.Fatalf("refusal must name where to %s: %+v", want, r.Cure)
|
||||
}
|
||||
if !reachable(kinds[want]) {
|
||||
if !cloud.Reachable(kinds[want]) {
|
||||
t.Fatalf("cure %q points at %q, which the gate itself would refuse", want, kinds[want])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
package entitlements
|
||||
|
||||
// standing.go answers the ONE question the paywall asks, and nothing else: how does
|
||||
// this caller STAND, commercially, for `product` right now? It is pure decide — no
|
||||
// HTTP, no flags, no refusal shaping. require.go applies the answer.
|
||||
//
|
||||
// TWO WAYS TO PAY, ONE ANSWER. The rule is "an active monthly subscription OR a
|
||||
// positive pay-as-you-go pre-pay balance". Those are two INDEPENDENT facts held by
|
||||
// two INDEPENDENT authorities, and this file is the single place they compose:
|
||||
//
|
||||
// - SUBSCRIPTION — commerce.CheckEntitlement(org, product). The plan→product
|
||||
// policy lives ONCE, in @hanzo/plans, and is resolved there. We read its
|
||||
// verdict; we never restate it.
|
||||
// - PREPAID CREDIT — the caller's wallet on the native finance ledger
|
||||
// (clients/finance, published at boot as finance.Current()). That is the SAME
|
||||
// ledger the ai gate reads, the edge meter debits, and an admin grant credits,
|
||||
// read at the SAME address (principal.Wallet) — see below.
|
||||
//
|
||||
// THE ADDRESS IS LOAD-BEARING. A money gate that reads a different wallet than the
|
||||
// debit writes is the bug this codebase has already shipped twice, both times by
|
||||
// keying the ORG POOL: "every new signup lives in 'hanzo', so a brand-new $0
|
||||
// account read HANZO's balance and sailed through the gate" (build.go wireFinance).
|
||||
// Read on the org pool, this paywall would admit every free signup in the shared
|
||||
// org for as long as the platform's own pool is funded — a total bypass. So the
|
||||
// credit leg reads principal.WalletOf's address and nothing else.
|
||||
//
|
||||
// CREDIT IS EXACT. finance.Balance returns money.Amount — 18-decimal atto-USD over
|
||||
// big.Int, the same integer an on-chain uint256 credit balance holds. The admit
|
||||
// boundary is Sign() > 0: zero denies, ONE ATTO admits. There is no threshold, no
|
||||
// cent-flooring, and no float anywhere on this path.
|
||||
//
|
||||
// PROOF, NOT ABSENCE. A caller is Unpaid only when BOTH authorities ANSWERED and
|
||||
// both said no. If either could not answer, the standing is Unknown — this file
|
||||
// never converts "the oracle is down" into "this customer has not paid". What to
|
||||
// DO about an Unknown is enforcement's decision (require.go), not the decide's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
)
|
||||
|
||||
// creditUnit is the asset the prepaid wallet is denominated in. One asset ships
|
||||
// (USD, 18-decimal-exact); finance selects the ledger file from it.
|
||||
const creditUnit = "usd"
|
||||
|
||||
// Standing is a caller's resolved commercial standing for one product. The zero
|
||||
// value is Unknown, which is the honest default: a question not yet asked has no
|
||||
// answer, and "no answer" is never "has not paid".
|
||||
type Standing uint8
|
||||
|
||||
const (
|
||||
// Unknown — at least one authority could not answer (commerce not co-resident,
|
||||
// entitlement query failed, ledger unreadable). NOT a statement about the caller.
|
||||
Unknown Standing = iota
|
||||
// Unpaid — BOTH authorities answered and both said no: no plan licenses the
|
||||
// product and the wallet holds no credit. The only proven refusal.
|
||||
Unpaid
|
||||
// Subscribed — an active @hanzo/plans entitlement licenses the product.
|
||||
Subscribed
|
||||
// Funded — the wallet holds a positive prepaid balance to burn down.
|
||||
Funded
|
||||
)
|
||||
|
||||
// Admits reports whether this standing lets a request through on its own merits.
|
||||
// Unknown does NOT admit here — it is not an admit, it is an absence, and the
|
||||
// posture that resolves it is enforcement's (require.go), deliberately not hidden
|
||||
// inside this predicate.
|
||||
func (s Standing) Admits() bool { return s == Subscribed || s == Funded }
|
||||
|
||||
// String renders the standing for logs.
|
||||
func (s Standing) String() string {
|
||||
switch s {
|
||||
case Unpaid:
|
||||
return "unpaid"
|
||||
case Subscribed:
|
||||
return "subscribed"
|
||||
case Funded:
|
||||
return "funded"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve reports how a caller stands for product.
|
||||
//
|
||||
// org — the tenant whose PLAN is checked (the validated, effective org).
|
||||
// product — the @hanzo/plans product id, BARE (commerce prepends the
|
||||
// "licensing.product:" prefix itself).
|
||||
// w — the money address the credit leg reads (principal.WalletOf).
|
||||
//
|
||||
// commerce may be nil (not co-resident); the finance ledger may be unpublished
|
||||
// (split deploy). Either absence makes that leg unresolvable, never a "no".
|
||||
//
|
||||
// The legs are evaluated cheapest-decisive-first: an entitled caller never touches
|
||||
// the ledger. A caller with no subscription always does — that is the pay-as-you-go
|
||||
// path, and it is the common case for a prepaid customer.
|
||||
func Resolve(ctx context.Context, commerce cloud.CommerceClient, org, product string, w principal.Wallet) Standing {
|
||||
entOK, entActive := licensedBy(ctx, commerce, org, product)
|
||||
if entOK && entActive {
|
||||
return Subscribed
|
||||
}
|
||||
creditOK, funded := creditIn(ctx, w)
|
||||
if creditOK && funded {
|
||||
return Funded
|
||||
}
|
||||
if entOK && creditOK {
|
||||
// Both authorities answered; both said no. This — and only this — is proof.
|
||||
return Unpaid
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
// licensedBy asks the ONE entitlement authority whether org's plan licenses product.
|
||||
// ok is false when commerce could not answer (nil client, query error, or a nil
|
||||
// result with no error — machinery that returned nothing has not said "no").
|
||||
func licensedBy(ctx context.Context, commerce cloud.CommerceClient, org, product string) (ok, active bool) {
|
||||
if commerce == nil {
|
||||
return false, false
|
||||
}
|
||||
ent, err := commerce.CheckEntitlement(ctx, org, product)
|
||||
if err != nil || ent == nil {
|
||||
return false, false
|
||||
}
|
||||
return true, ent.Active
|
||||
}
|
||||
|
||||
// creditIn reads the caller's prepaid balance from the native finance ledger and
|
||||
// reports whether it is positive. ok is false when the ledger is not published
|
||||
// (split deploy / money layer not co-resident), when the request carries no wallet,
|
||||
// or when the read FAILS — "a balance that cannot be read is unknown, never zero"
|
||||
// (clients/finance.Balance). A zero balance IS an answer: (true, false).
|
||||
//
|
||||
// The read is against the LIVE books (test=false). Sandbox money must never buy
|
||||
// live product access.
|
||||
func creditIn(ctx context.Context, w principal.Wallet) (ok, funded bool) {
|
||||
if w.Account == "" {
|
||||
return false, false
|
||||
}
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return false, false
|
||||
}
|
||||
bal, err := fin.Balance(ctx, w.Ledger, w.Account, creditUnit, false)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return true, bal.Sign() > 0
|
||||
}
|
||||
@@ -286,14 +286,12 @@ type Config struct {
|
||||
CommerceServiceToken string
|
||||
BillingFailOpen bool
|
||||
|
||||
// PaywallEnforced turns on the subscription paywall (routers.Paywall): when true,
|
||||
// a gated /v1 product route from a validated org with NO active paid plan
|
||||
// (Pro/Plus/Max/Team/Enterprise, active or trialing) is refused with 402
|
||||
// subscription_required. Default FALSE — a DARK SHIP: the gate is a pure
|
||||
// passthrough until an owner sets PAYWALL_ENFORCED=true, so wiring it changes no
|
||||
// behavior. The sell/service surface (sign-in/billing/plans/models/health) is always
|
||||
// exempt so the gate can never block paying.
|
||||
PaywallEnforced bool
|
||||
// The spend gate (middleware_spend.go) is deliberately NOT configured here. It reads
|
||||
// the `paywall_enforced` platform switch and nothing else, so the admin cockpit is
|
||||
// its single source of truth and the kill switch can always disarm it. A
|
||||
// PAYWALL_ENFORCED field here ORed with the switch, which meant an env var could arm
|
||||
// a gate the cockpit could not turn off — the one failure mode a kill switch exists
|
||||
// to prevent. One reader, one answer.
|
||||
|
||||
// AI inference gateway. Two DISTINCT endpoints, two DISTINCT credentials by
|
||||
// concern (see build.go pickCompletionsClient / pickEmbedClient):
|
||||
@@ -442,7 +440,6 @@ func LoadConfig() *Config {
|
||||
CommerceHTTPURL: getenv("CLOUD_COMMERCE_HTTP_URL", ""),
|
||||
CommerceServiceToken: getenv("COMMERCE_SERVICE_TOKEN", ""),
|
||||
BillingFailOpen: getenvBool("BILLING_FAIL_OPEN"),
|
||||
PaywallEnforced: getenvBool("PAYWALL_ENFORCED"), // dark ship: default false.
|
||||
// AI inference gateway. CLOUD_AI_API_KEY (KMS-backed) is an optional static
|
||||
// override; absent it, the AI client authenticates via M2M using the
|
||||
// binary's own IAM identity (IAM_CLIENT_ID / IAM_CLIENT_SECRET) — no static
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package cloud
|
||||
|
||||
// middleware_spend.go APPLIES the spend predicate; spend.go COMPUTES it. This file
|
||||
// decides only WHETHER and HOW to refuse: the kill switch, the fail posture, and the
|
||||
// shape of the 402. It never restates policy.
|
||||
//
|
||||
// It replaces routers.Paywall, which serve.go mounted app-wide and which asked the
|
||||
// wrong question — "does the org hold a paid PLAN?" with no credit leg — so turning
|
||||
// it on would have 402'd every prepaid customer. That is why it shipped dark and
|
||||
// stayed dark. SpendGate asks the question the money actually turns on: subscription
|
||||
// OR prepaid credit, read at the wallet address the DEBIT writes.
|
||||
//
|
||||
// WHAT IT CLOSES. A stranger can self-signup today and run inference we pay a
|
||||
// provider for, at zero balance. The reason is not missing auth — every gate on the
|
||||
// path checks auth. It is that the two live gates check the WRONG THING:
|
||||
//
|
||||
// - the edge BillingGate gates on price(path) > 0, and DefaultPrice returns 0
|
||||
// everywhere, so it never evaluates;
|
||||
// - zen's commerceGate (apps/zen.go) gates the caller's ORG POOL, and every
|
||||
// self-serve signup lands in the shared "hanzo" org whose pool is funded — so a
|
||||
// brand-new $0 account reads a six-figure balance and sails through;
|
||||
// - zen's own Tenant.Valid() is `t.Org != ""` — auth, not balance.
|
||||
//
|
||||
// SpendGate is one gate, at one place, on the one predicate, covering the LLM paths
|
||||
// and the non-LLM resource trees alike (Billable), so neither can be fixed without
|
||||
// the other.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// PlanChecker is the ONE commerce read this gate needs: does org X hold a LIVE
|
||||
// (active or trialing) PAID plan? It is a consumer-defined interface satisfied
|
||||
// structurally by the co-resident commerce client (clients/commerce.ActivePaidPlan) —
|
||||
// an OPTIONAL capability resolved from Deps.Commerce by type-assertion, so the narrow
|
||||
// types.CommerceClient interface is untouched and a commerce build that cannot answer
|
||||
// (split deploy / disabled stub) simply yields LicenceUnknown.
|
||||
//
|
||||
// - (tier, true, nil) -> LicenceActive.
|
||||
// - ("", false, nil) -> LicenceNone: resolved, no live paid plan.
|
||||
// - (_, _, err) -> LicenceUnknown: machinery failure, never a "no".
|
||||
type PlanChecker interface {
|
||||
ActivePaidPlan(ctx context.Context, org string) (tier string, paid bool, err error)
|
||||
}
|
||||
|
||||
// SpendGate returns the balance-and-subscription gate serve.go mounts app-wide.
|
||||
//
|
||||
// DEFAULT OFF, AND THAT IS A SEQUENCING DECISION, NOT TIMIDITY. There is no reachable
|
||||
// starter-credit path in this binary today: the grant-starter route was deleted from
|
||||
// commerce (last present in commerce v1.48.2), its replacement POST /v1/billing/credit
|
||||
// is not registered in the co-resident build, and no ensureStarterCredit runs
|
||||
// anywhere. So a brand-new signup's wallet is $0 with no self-service way to fund it.
|
||||
// Enforcing on that state 402s every new signup on day one — trading a revenue leak
|
||||
// for a total signup outage. The gate therefore ships behind the kill switch, proven
|
||||
// by tests, and is flipped only after a funding path exists. Turning it on before then
|
||||
// is the one way to make this worse.
|
||||
//
|
||||
// Enforcement is read PER REQUEST from the platform switch an owner flips at
|
||||
// admin.hanzo.ai, so it turns on and off within one flag-cache TTL — no redeploy, no
|
||||
// CR edit. Switch reports false before the flag engine mounts, so an unmounted switch
|
||||
// never enforces.
|
||||
//
|
||||
// DECISION ORDER — a request is ADMITTED unless the one proven refusal fires:
|
||||
//
|
||||
// 1. enforcement OFF ........................ admit. Read FIRST, so a dark gate costs
|
||||
// one atomic load and touches no authority.
|
||||
// 2. not Billable ........................... admit. Reads, the pay path, and every
|
||||
// non-metered route are never gated (see Billable / Reachable).
|
||||
// 3. unvalidated principal .................. admit. An anonymous caller is the
|
||||
// route's own 401 to make; a 402 is meaningless to someone not signed in, and
|
||||
// the org on such a request is a forge anyway.
|
||||
// 4. platform super-admin ................... admit. Platform sudo, including
|
||||
// masquerade, is strictly tighter than any purchasable tier and never 402s.
|
||||
// 5. subscribed OR funded ................... admit.
|
||||
// 6. UNRESOLVABLE standing .................. posture (paywall_strict), logged WARN.
|
||||
// 7. proven unpaid .......................... 402 with the actionable refusal.
|
||||
//
|
||||
// FAIL POSTURE — argued, not inherited. The refusal in (7) requires PROOF: both
|
||||
// authorities answered and both said no. An authority we could not reach yields
|
||||
// Unknown, and by default an Unknown ADMITS. That is not "unpaid users get everything
|
||||
// free whenever commerce hiccups" — it is "we never call a customer delinquent on
|
||||
// evidence we do not have". The asymmetry decides it: refusing on an outage 402s every
|
||||
// PAYING customer at once (a total product outage, irreversible), while admitting on
|
||||
// an outage leaks access for the duration (bounded, recoverable, and separately capped
|
||||
// — the subsystem meters still run their own fail-closed debit). Every fail-open admit
|
||||
// logs at WARN with the reason, so a sustained leak pages rather than hides. When an
|
||||
// owner wants the other trade, paywall_strict flips it live.
|
||||
//
|
||||
// An unresolvable WALLET is an Unknown, not an admit: principal.WalletOf refuses an
|
||||
// unresolvable org, and "we could not work out who pays" must refuse AS UNKNOWN and
|
||||
// take the strict posture, never be silently waved through as if it were free.
|
||||
func SpendGate(commerce CommerceClient) zip.Handler {
|
||||
plans, _ := commerce.(PlanChecker)
|
||||
return func(c *zip.Ctx) error {
|
||||
if !Switch(SwitchPaywallEnforced) {
|
||||
return c.Next() // dark — byte-identical to no middleware at all.
|
||||
}
|
||||
if !Billable(c.Method(), c.Path()) {
|
||||
return c.Next()
|
||||
}
|
||||
if !principal.Validated(c) {
|
||||
return c.Next() // the route's own auth answers; a 402 would mask a 401.
|
||||
}
|
||||
if principal.IsSuperAdmin(c) {
|
||||
return c.Next() // platform sudo, masquerade included.
|
||||
}
|
||||
w, ok := principal.WalletOf(c)
|
||||
if !ok {
|
||||
// No resolvable payer. Unknown, not unpaid, and never a free pass.
|
||||
return unresolved(c, "no resolvable wallet")
|
||||
}
|
||||
switch s := Stand(c.Context(), licence(c.Context(), plans, w.Ledger), w); {
|
||||
case s.Admits():
|
||||
return c.Next()
|
||||
case s == Unknown:
|
||||
return unresolved(c, "billing authority unreadable")
|
||||
default:
|
||||
c.Log().Info("spend: no subscription and no credit", "org", w.Ledger, "account", w.Account, "path", c.Path())
|
||||
return Refuse(c, "", ReasonUnpaid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// licence resolves the subscription leg for the spend gate. A nil PlanChecker (commerce
|
||||
// not co-resident) and any query error are both LicenceUnknown — machinery that could
|
||||
// not answer has not said "no".
|
||||
func licence(ctx context.Context, plans PlanChecker, org string) Licence {
|
||||
if plans == nil || org == "" {
|
||||
return LicenceUnknown
|
||||
}
|
||||
_, paid, err := plans.ActivePaidPlan(ctx, org)
|
||||
if err != nil {
|
||||
return LicenceUnknown
|
||||
}
|
||||
if paid {
|
||||
return LicenceActive
|
||||
}
|
||||
return LicenceNone
|
||||
}
|
||||
|
||||
// unresolved applies the posture for a standing nobody could determine. OFF (the
|
||||
// default) admits and WARNs; paywall_strict refuses with the DISTINCT unresolved code,
|
||||
// because the caller's cure is different — an unpaid caller must buy something, an
|
||||
// unresolved one must retry.
|
||||
func unresolved(c *zip.Ctx, why string) error {
|
||||
if Switch(SwitchPaywallStrict) {
|
||||
c.Log().Warn("spend: standing unresolvable; refusing (strict)", "why", why, "path", c.Path())
|
||||
return Refuse(c, "", ReasonUnresolved)
|
||||
}
|
||||
c.Log().Warn("spend: standing unresolvable; admitting", "why", why, "path", c.Path())
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// ── the refusal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// The two reasons a 402 can carry, spelled once for every gate. They are DISTINCT
|
||||
// codes because the caller's cure is different: an unpaid caller must buy something,
|
||||
// an unresolved one must retry.
|
||||
const (
|
||||
ReasonUnpaid = "unpaid"
|
||||
ReasonUnresolved = "unresolved"
|
||||
)
|
||||
|
||||
// Refusal is the 402 body. A bare 402 is useless to the console shell, so this names
|
||||
// WHAT is gated, WHY, and every way to cure it — one Cure per admit leg, so the body
|
||||
// is the structural mirror of the predicate and can never drift from it.
|
||||
//
|
||||
// The cure paths are RELATIVE and same-origin, deliberately: a hard-coded
|
||||
// cloud.hanzo.ai would brand a Lux / Zoo / Pars deployment as Hanzo, and this binary
|
||||
// white-labels by host. They point at the two API surfaces the shell already reads and
|
||||
// that Reachable guarantees are never gated — /v1/plans (what to buy, with prices) and
|
||||
// /v1/billing (where to pay, subscribe or top up).
|
||||
type Refusal struct {
|
||||
Error string `json:"error"` // stable machine code: always "payment_required"
|
||||
Product string `json:"product,omitempty"` // the gated product id, when the gate is product-scoped
|
||||
Reason string `json:"reason"` // "unpaid" | "unresolved"
|
||||
Message string `json:"message"` // one human sentence
|
||||
Cure []Cure `json:"cure"` // the ways to fix it, in the order to offer them
|
||||
}
|
||||
|
||||
// Cure is one way out of a 402: Kind names the admit leg it satisfies
|
||||
// ("subscribe" | "credit"), URL where to do it.
|
||||
type Cure struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// cures are the two ways to cure a refusal — exactly the two legs Stand admits on, in
|
||||
// the order to offer them (a subscription is the steady state; prepay is the
|
||||
// no-commitment path).
|
||||
var cures = []Cure{
|
||||
{Kind: "subscribe", URL: "/v1/plans"},
|
||||
{Kind: "credit", URL: "/v1/billing"},
|
||||
}
|
||||
|
||||
// Refuse renders the ONE 402 shape every spend gate uses. product is empty for the
|
||||
// edge gate (which gates spend itself, not a product) and set by a product-scoped
|
||||
// gate; `for <product>` is the only difference it makes to the sentence.
|
||||
func Refuse(c *zip.Ctx, product, reason string) error {
|
||||
scope := ""
|
||||
if product != "" {
|
||||
scope = " for " + product
|
||||
}
|
||||
msg := "no active subscription" + scope + " and no prepaid credit"
|
||||
if reason == ReasonUnresolved {
|
||||
msg = "billing could not be verified" + scope + "; retry shortly"
|
||||
}
|
||||
return c.JSON(http.StatusPaymentRequired, Refusal{
|
||||
Error: "payment_required",
|
||||
Product: product,
|
||||
Reason: reason,
|
||||
Message: msg,
|
||||
Cure: cures,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package cloud
|
||||
|
||||
// Tests for the ONE spend predicate and the gate that applies it.
|
||||
//
|
||||
// They drive real requests through the zip/fiber stack against a fake finance
|
||||
// ledger published on the SAME process-wide seam the ai gate and the edge meter
|
||||
// resolve through (finance.Publish), so the address the gate reads is the address a
|
||||
// debit would write. Nothing about the predicate is mocked.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── harness ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// spendLedger is an in-memory finance ledger. It answers every address with the same
|
||||
// balance (the tables are about the VERDICT), but records the address it was asked
|
||||
// for so a dedicated test can assert the gate reads the wallet the debit writes.
|
||||
type spendLedger struct {
|
||||
credit money.Amount
|
||||
err error
|
||||
reads int
|
||||
ledger string // last Balance() org
|
||||
account string // last Balance() subject
|
||||
}
|
||||
|
||||
var _ types.FinanceClient = (*spendLedger)(nil)
|
||||
|
||||
func (f *spendLedger) Balance(_ context.Context, org, subject, _ string, _ bool) (money.Amount, error) {
|
||||
f.reads++
|
||||
f.ledger, f.account = org, subject
|
||||
if f.err != nil {
|
||||
return money.Zero(), f.err
|
||||
}
|
||||
return f.credit, nil
|
||||
}
|
||||
|
||||
func (f *spendLedger) Deposit(context.Context, types.DepositInput) (string, error) { return "", nil }
|
||||
func (f *spendLedger) RecordUsage(context.Context, types.UsageInput) error { return nil }
|
||||
func (f *spendLedger) SumUsageSince(context.Context, string, bool, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// publishLedger installs a fake ledger as the process-wide money seam, restoring the
|
||||
// prior one on cleanup. nil models a split deploy: no co-resident money layer.
|
||||
func publishLedger(t *testing.T, f *spendLedger) {
|
||||
t.Helper()
|
||||
prev := finance.Current()
|
||||
if f == nil {
|
||||
finance.Publish(nil)
|
||||
} else {
|
||||
finance.Publish(f)
|
||||
}
|
||||
t.Cleanup(func() { finance.Publish(prev) })
|
||||
}
|
||||
|
||||
// atto builds an exact 18-decimal credit amount from a whole-atto integer, so the
|
||||
// admit boundary is asserted at the true unit — no cents, no rounding.
|
||||
func atto(n int64) money.Amount { return money.FromAtto(big.NewInt(n)) }
|
||||
|
||||
// switches installs a platform-switch reader for the duration of a test. This is the
|
||||
// ONLY way enforcement turns on, which is itself the property under test.
|
||||
func switches(t *testing.T, on map[string]bool) {
|
||||
t.Helper()
|
||||
SetSwitchReader(func(k string) bool { return on[k] })
|
||||
t.Cleanup(func() { SetSwitchReader(nil) })
|
||||
}
|
||||
|
||||
// planStub is the subscription authority. paid/err drive the licence leg.
|
||||
type planStub struct {
|
||||
paid bool
|
||||
err error
|
||||
// CommerceClient is embedded (nil) purely so planStub satisfies the interface
|
||||
// SpendGate takes; only ActivePaidPlan is ever called.
|
||||
CommerceClient
|
||||
}
|
||||
|
||||
func (p *planStub) ActivePaidPlan(context.Context, string) (string, bool, error) {
|
||||
if p.err != nil {
|
||||
return "", false, p.err
|
||||
}
|
||||
return "pro", p.paid, nil
|
||||
}
|
||||
|
||||
// spendProbe mounts SpendGate ahead of a terminal 200 handler, so a test asserts the
|
||||
// verdict by status: 200 = admitted (reached the handler), 402 = refused by the gate.
|
||||
func spendProbe(commerce CommerceClient) *zip.App {
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SpendGate(commerce))
|
||||
h := func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "served"}) }
|
||||
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/billing/credit", "/v1/ml/train", "/v1/health"} {
|
||||
app.Post(p, h)
|
||||
app.Get(p, h)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// call drives one request and returns status + body.
|
||||
func call(t *testing.T, app *zip.App, method, path string, hdr map[string]string) (int, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
return resp.StatusCode, buf[:n]
|
||||
}
|
||||
|
||||
// member is a plain validated, non-admin principal in org `hanzo` — precisely the
|
||||
// self-serve signup shape the free-inference hole lives in.
|
||||
var member = map[string]string{
|
||||
"X-User-Id": "u_1",
|
||||
"X-User-Name": "stranger",
|
||||
"X-Org-Id": "hanzo",
|
||||
"X-User-Owner": "hanzo",
|
||||
}
|
||||
|
||||
// ── the split: billable(path) is NOT price(path) ────────────────────────────────
|
||||
|
||||
// TestBillableIsNotPrice is the regression that names the whole bug. Authorization
|
||||
// and pricing were ONE int64: DefaultPrice returns 0 for every inference path (so the
|
||||
// subsystems can self-meter without double-billing) and BillingGate read `cents <= 0`
|
||||
// as "do not gate". Pricing at zero therefore silently un-AUTHORIZED the path. If
|
||||
// these two ever agree again, the leak is back.
|
||||
func TestBillableIsNotPrice(t *testing.T) {
|
||||
for _, path := range []string{"/v1/chat/completions", "/v1/messages", "/v1/ai/chat", "/v1/ml/train"} {
|
||||
app := zip.New(zip.Config{})
|
||||
var priced int64
|
||||
app.Post(path, func(c *zip.Ctx) error { priced = DefaultPrice(c); return c.JSON(200, "") })
|
||||
if _, _ = call(t, app, http.MethodPost, path, nil); priced != 0 {
|
||||
t.Fatalf("%s: DefaultPrice = %d, want 0 (the subsystem self-meters)", path, priced)
|
||||
}
|
||||
if !Billable(http.MethodPost, path) {
|
||||
t.Fatalf("%s: priced at 0 AND not billable — that fusion is the free-inference hole", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillable(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
// The free-inference hole: every path a paid upstream serves.
|
||||
{"POST", "/v1/chat/completions", true, "the default chat surface"},
|
||||
{"POST", "/v1/messages", true, "the Anthropic-shaped surface zen also claims"},
|
||||
{"POST", "/v1/completions", true, "legacy completions"},
|
||||
{"POST", "/v1/embeddings", true, "embeddings call a paid upstream too"},
|
||||
{"POST", "/v1/responses", true, "the responses surface"},
|
||||
{"POST", "/v1/ai/chat", true, "ai's own tree"},
|
||||
|
||||
// The non-LLM auth-not-balance gap — the same predicate, not a second one.
|
||||
{"POST", "/v1/ml/train", true, "provisioned compute"},
|
||||
{"POST", "/v1/s3/bucket", true, "object storage data plane"},
|
||||
{"POST", "/v1/agents/run", true, "per-run agent fee"},
|
||||
{"POST", "/v1/security/scan", true, "scan fee"},
|
||||
|
||||
// Reads are NEVER billable. Gating them has already caused one outage, and a
|
||||
// balance view that 402s is unusable.
|
||||
{"GET", "/v1/chat/completions", false, "a read never spends"},
|
||||
{"HEAD", "/v1/ml/train", false, "a read never spends"},
|
||||
{"OPTIONS", "/v1/chat/completions", false, "CORS preflight must never 402"},
|
||||
|
||||
// The path to payment, and the surfaces that render it.
|
||||
{"POST", "/v1/billing/credit", false, "topping up must not require credit"},
|
||||
{"POST", "/v1/billing/webhooks/square", false, "gating an inbound payment webhook loses payments"},
|
||||
{"POST", "/v1/commerce/checkout", false, "the checkout plane"},
|
||||
{"POST", "/v1/iam/token", false, "you must be able to sign in"},
|
||||
{"POST", "/v1/admin/grants", false, "the cockpit holds this gate's kill switch"},
|
||||
{"POST", "/v1/signin", false, "session bootstrap"},
|
||||
{"POST", "/anything", false, "non-/v1 is the SPA shell"},
|
||||
|
||||
// Not metered: telemetry ingest and liveness spend nobody's money.
|
||||
{"POST", "/v1/o11y/ingest", false, "telemetry ingest is not user-billable"},
|
||||
{"POST", "/v1/ml/health", false, "a probe is never billed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Billable(tc.method, tc.path); got != tc.want {
|
||||
t.Errorf("Billable(%s %s) = %v, want %v — %s", tc.method, tc.path, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the gate ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSpendGate(t *testing.T) {
|
||||
broke := errors.New("commerce unreachable")
|
||||
cases := []struct {
|
||||
name string
|
||||
enforced bool
|
||||
strict bool
|
||||
plans *planStub
|
||||
ledger *spendLedger
|
||||
hdr map[string]string
|
||||
path string
|
||||
method string
|
||||
want int
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "DARK by default: a $0 stranger is admitted and NO authority is consulted",
|
||||
// This is the shipped posture. It must stay this way until a starter-credit
|
||||
// path exists, or enforcement 402s every new signup on day one.
|
||||
enforced: false, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
{
|
||||
name: "THE HOLE: enforced, $0 stranger on the default chat surface → 402",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, want: http.StatusPaymentRequired, reason: ReasonUnpaid,
|
||||
},
|
||||
{
|
||||
name: "subscription, no credit → admit",
|
||||
enforced: true, plans: &planStub{paid: true}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
{
|
||||
name: "credit, no subscription → admit (pay-as-you-go)",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(1)},
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
{
|
||||
name: "ONE ATTO admits — the boundary is Sign() > 0, not a cent",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(1)},
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
{
|
||||
name: "zero atto denies",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, want: http.StatusPaymentRequired, reason: ReasonUnpaid,
|
||||
},
|
||||
|
||||
// UNKNOWN IS NOT UNPAID.
|
||||
{
|
||||
name: "ledger unreadable + plan says no → UNKNOWN, admitted (default posture)",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{err: broke},
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
{
|
||||
name: "no co-resident ledger + plan says no → UNKNOWN, admitted",
|
||||
enforced: true, plans: &planStub{}, ledger: nil,
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
{
|
||||
name: "strict: UNKNOWN refuses, and with the DISTINCT unresolved code",
|
||||
enforced: true, strict: true, plans: &planStub{err: broke}, ledger: nil,
|
||||
hdr: member, want: http.StatusPaymentRequired, reason: ReasonUnresolved,
|
||||
},
|
||||
{
|
||||
name: "strict does NOT refuse a FUNDED caller whose plan is unreadable",
|
||||
enforced: true, strict: true, plans: &planStub{err: broke}, ledger: &spendLedger{credit: atto(1)},
|
||||
hdr: member, want: 200,
|
||||
},
|
||||
|
||||
// Legitimate zero-balance paths that MUST survive enforcement.
|
||||
{
|
||||
name: "SuperAdmin masquerade never 402s",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: map[string]string{
|
||||
"X-User-Id": "u_admin", "X-User-Name": "root", "X-Org-Id": "victim",
|
||||
"X-User-Owner": "admin", "X-User-IsAdmin": "true",
|
||||
},
|
||||
want: 200,
|
||||
},
|
||||
{
|
||||
name: "anonymous is the route's own 401 to make, never a 402",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: map[string]string{"X-Org-Id": "hanzo"}, // no X-User-Id ⟹ unvalidated
|
||||
want: 200,
|
||||
},
|
||||
{
|
||||
name: "a READ is never gated, even at $0",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, method: http.MethodGet, want: 200,
|
||||
},
|
||||
{
|
||||
name: "the pay path is never gated, even at $0",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, path: "/v1/billing/credit", want: 200,
|
||||
},
|
||||
{
|
||||
name: "the model catalog stays readable at $0",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, path: "/v1/models", want: 200,
|
||||
},
|
||||
|
||||
// The non-LLM gap closes through the SAME gate, not a second one.
|
||||
{
|
||||
name: "non-LLM provisioning is gated by the same predicate",
|
||||
enforced: true, plans: &planStub{}, ledger: &spendLedger{credit: atto(0)},
|
||||
hdr: member, path: "/v1/ml/train", want: http.StatusPaymentRequired, reason: ReasonUnpaid,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
switches(t, map[string]bool{
|
||||
SwitchPaywallEnforced: tc.enforced,
|
||||
SwitchPaywallStrict: tc.strict,
|
||||
})
|
||||
publishLedger(t, tc.ledger)
|
||||
|
||||
path := tc.path
|
||||
if path == "" {
|
||||
path = "/v1/chat/completions"
|
||||
}
|
||||
method := tc.method
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
code, body := call(t, spendProbe(tc.plans), method, path, tc.hdr)
|
||||
if code != tc.want {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", code, tc.want, body)
|
||||
}
|
||||
if tc.reason != "" {
|
||||
var r Refusal
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatalf("decode Refusal: %v (body=%s)", err, body)
|
||||
}
|
||||
if r.Reason != tc.reason {
|
||||
t.Fatalf("Refusal.Reason = %q, want %q", r.Reason, tc.reason)
|
||||
}
|
||||
if r.Error != "payment_required" || r.Message == "" || len(r.Cure) == 0 {
|
||||
t.Fatalf("refusal must be actionable: %+v", r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpendGateReadsTheWalletTheDebitWrites is the property every prior recurrence of
|
||||
// this bug violated: the gate read the ORG POOL while the debit spent the PERSON's
|
||||
// wallet. In the shared signup org those are different addresses and the pool is
|
||||
// funded, so a brand-new $0 account read a six-figure balance and sailed through —
|
||||
// which is the live free-inference hole (apps/zen.go still gates the pool today).
|
||||
//
|
||||
// The address must be principal.WalletOf's: ledger "hanzo", account "hanzo/stranger"
|
||||
// — NOT the bare org slug, which finance resolves to the pool.
|
||||
func TestSpendGateReadsTheWalletTheDebitWrites(t *testing.T) {
|
||||
switches(t, map[string]bool{SwitchPaywallEnforced: true})
|
||||
led := &spendLedger{credit: atto(0)}
|
||||
publishLedger(t, led)
|
||||
|
||||
code, body := call(t, spendProbe(&planStub{}), http.MethodPost, "/v1/chat/completions", member)
|
||||
if code != http.StatusPaymentRequired {
|
||||
t.Fatalf("status = %d, want 402 (body=%s)", code, body)
|
||||
}
|
||||
if led.reads == 0 {
|
||||
t.Fatal("the gate never read a balance — it cannot be gating on one")
|
||||
}
|
||||
if led.ledger != "hanzo" {
|
||||
t.Fatalf("ledger = %q, want %q (the org whose books hold the wallet)", led.ledger, "hanzo")
|
||||
}
|
||||
if led.account != "hanzo/stranger" {
|
||||
t.Fatalf("account = %q, want %q — reading the bare org slug is the POOL, "+
|
||||
"and the funded pool of the shared signup org is exactly what admits every "+
|
||||
"free rider", led.account, "hanzo/stranger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpendGateDarkConsultsNothing pins the kill switch's cost and its honesty: OFF
|
||||
// must not merely admit, it must consult NO authority at all. A dark gate that still
|
||||
// reads the ledger would make the switch a performance lie and could take the money
|
||||
// layer's outage into a path that is supposed to be inert.
|
||||
func TestSpendGateDarkConsultsNothing(t *testing.T) {
|
||||
switches(t, map[string]bool{SwitchPaywallEnforced: false})
|
||||
led := &spendLedger{credit: atto(0)}
|
||||
publishLedger(t, led)
|
||||
|
||||
if code, body := call(t, spendProbe(&planStub{}), http.MethodPost, "/v1/chat/completions", member); code != 200 {
|
||||
t.Fatalf("dark gate must admit: status = %d (body=%s)", code, body)
|
||||
}
|
||||
if led.reads != 0 {
|
||||
t.Fatalf("dark gate consulted the ledger %d times — it must touch no authority", led.reads)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpendGateUnmountedSwitchNeverEnforces is the boot-order safety property: before
|
||||
// the flag engine mounts, Switch reports false. If that ever inverted, every request
|
||||
// served during startup would 402.
|
||||
func TestSpendGateUnmountedSwitchNeverEnforces(t *testing.T) {
|
||||
SetSwitchReader(nil)
|
||||
publishLedger(t, &spendLedger{credit: atto(0)})
|
||||
if code, body := call(t, spendProbe(&planStub{}), http.MethodPost, "/v1/chat/completions", member); code != 200 {
|
||||
t.Fatalf("no flag engine mounted must mean no enforcement: status = %d (body=%s)", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the predicate, directly ─────────────────────────────────────────────────────
|
||||
|
||||
// TestStandNeverInventsAnUnpaid is the money-correctness invariant: Unpaid requires
|
||||
// BOTH authorities to have answered no. Anything less is Unknown, and enforcement —
|
||||
// not this predicate — decides what to do about that.
|
||||
func TestStandNeverInventsAnUnpaid(t *testing.T) {
|
||||
w := principal.Wallet{Ledger: "hanzo", Account: "hanzo/stranger"}
|
||||
cases := []struct {
|
||||
name string
|
||||
lic Licence
|
||||
ledger *spendLedger
|
||||
want Standing
|
||||
}{
|
||||
{"both said no → the only proven refusal", LicenceNone, &spendLedger{credit: atto(0)}, Unpaid},
|
||||
{"licence unknown, wallet empty → UNKNOWN, not unpaid", LicenceUnknown, &spendLedger{credit: atto(0)}, Unknown},
|
||||
{"licence says no, ledger unreadable → UNKNOWN", LicenceNone, &spendLedger{err: errors.New("down")}, Unknown},
|
||||
{"licence says no, no ledger published → UNKNOWN", LicenceNone, nil, Unknown},
|
||||
{"active licence short-circuits, ledger untouched", LicenceActive, nil, Subscribed},
|
||||
{"one atto is credit", LicenceNone, &spendLedger{credit: atto(1)}, Funded},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
publishLedger(t, tc.ledger)
|
||||
if got := Stand(context.Background(), tc.lic, w); got != tc.want {
|
||||
t.Fatalf("Stand = %v, want %v", got, tc.want)
|
||||
}
|
||||
if tc.want == Unknown && Unknown.Admits() {
|
||||
t.Fatal("Unknown must never admit on its own merits")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStandSubscribedNeverReadsTheLedger pins the cheapest-decisive-first order: an
|
||||
// entitled caller must not pay a ledger round trip on every request.
|
||||
func TestStandSubscribedNeverReadsTheLedger(t *testing.T) {
|
||||
led := &spendLedger{credit: atto(0)}
|
||||
publishLedger(t, led)
|
||||
if got := Stand(context.Background(), LicenceActive, principal.Wallet{Ledger: "acme", Account: "acme/bob"}); got != Subscribed {
|
||||
t.Fatalf("Stand = %v, want Subscribed", got)
|
||||
}
|
||||
if led.reads != 0 {
|
||||
t.Fatalf("a subscribed caller read the ledger %d times; the legs are ordered to avoid that", led.reads)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// Package routers holds cross-cutting request filters that run on the unified cloud
|
||||
// binary's edge, AFTER the identity boundary (SanitizeIdentity) has resolved the
|
||||
// caller's principal — so every gate keys on a VALIDATED IAM owner claim, never a raw
|
||||
// client header.
|
||||
//
|
||||
// paywall.go is the subscription gate. When enforcement is ON, a request to a gated
|
||||
// /v1 product route from a validated org that has NO active paid plan is refused with
|
||||
// 402 subscription_required, steering the caller to the upgrade page. Every route the
|
||||
// sign-in / billing / plans / model-catalog / health surface needs to SELL and SERVICE
|
||||
// that upgrade stays open, so the gate can never lock a user out of paying.
|
||||
package routers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// PlanChecker is the ONE commerce read the paywall needs: does org X hold a LIVE
|
||||
// (active or trialing) PAID plan, and which tier. It is a consumer-defined interface
|
||||
// (idiomatic Go) satisfied structurally by the co-resident commerce client
|
||||
// (clients/commerce.ActivePaidPlan) — an OPTIONAL capability resolved from
|
||||
// deps.Commerce by type-assertion (mirrors types.ModelLister), so the narrow
|
||||
// types.CommerceClient interface is untouched and a commerce build that cannot answer
|
||||
// (split-deploy / disabled stub) yields a nil PlanChecker → the paywall fails OPEN.
|
||||
//
|
||||
// - (tier, true, nil) — a live paid plan; admit.
|
||||
// - ("", false, nil) — resolved, NO live paid plan; the 402 case.
|
||||
// - (_, _, err) — machinery failure; admit (fail open — never lock out).
|
||||
type PlanChecker interface {
|
||||
ActivePaidPlan(ctx context.Context, org string) (tier string, paid bool, err error)
|
||||
}
|
||||
|
||||
// Paywall returns the subscription-gate middleware.
|
||||
//
|
||||
// enforced is READ PER REQUEST, not once at mount. That is the whole point: the value
|
||||
// comes from the cockpit switch an owner flips at admin.hanzo.ai, so enforcement turns
|
||||
// on and off within one flag-cache TTL with no redeploy and no CR edit. Evaluating it
|
||||
// at mount time — as this did — meant the only way to flip the gate was to restart the
|
||||
// binary, and it also meant the switch already registered in the cockpit governed
|
||||
// nothing. A nil func is the dark default: a pure passthrough, ZERO behavior change.
|
||||
//
|
||||
// This package deliberately does NOT import the flag engine. It takes a func and stays
|
||||
// a pure request filter, so the caller decides where the answer comes from.
|
||||
//
|
||||
// NOT YET WIRED to the cockpit, and the reason is structural: clients/flags imports the
|
||||
// ROOT package (cloud.Deps/Handle/OrgStore/...), and the root package imports this one
|
||||
// to mount the middleware — so root can never import flags, and no edge filter mounted
|
||||
// from serve.go can read a switch. That is why the `paywall_enforced` switch registered
|
||||
// in the cockpit governs clients/entitlements.RequireProduct (a leaf, which may import
|
||||
// flags) and NOT this middleware, even though this one is what serve.go actually mounts.
|
||||
// Closing that gap means either inverting flags off the root package, or moving
|
||||
// enforcement onto RequireProduct at the gated route groups. Until then serve.go passes
|
||||
// the PAYWALL_ENFORCED config value and a flip still needs a redeploy.
|
||||
//
|
||||
// plans is the commerce plan read; nil ⇒ fail open (commerce cannot answer, so the gate
|
||||
// never blocks). It is resolved from deps.Commerce by the caller (serve.go) via a
|
||||
// type-assertion to PlanChecker.
|
||||
//
|
||||
// Decision order — a request is ADMITTED unless the ONE definitive deny fires:
|
||||
// 1. !enforced ................................ admit (dark ship).
|
||||
// 2. non-/v1 path (SPA shell, static assets,
|
||||
// /healthz, /readyz, /zap) ................ admit (the paywall gates the /v1 product
|
||||
// API only — never the app that renders the upgrade prompt).
|
||||
// 3. allow-listed /v1 path (auth, billing,
|
||||
// plans, models, health, entitlements) .... admit (the sell/service surface).
|
||||
// 4. no validated principal ................... admit (an anonymous caller is the route's
|
||||
// own 401/403 to make; a 402 upgrade prompt is meaningless to someone not signed in,
|
||||
// and the org is untrusted anyway).
|
||||
// 5. platform super-admin ..................... admit (operator bypass).
|
||||
// 6. org unresolved / plans nil / plans error admit (fail open — never lock out).
|
||||
// 7. org HAS a live paid plan ................. admit.
|
||||
// 8. otherwise ................................ 402 subscription_required.
|
||||
func Paywall(enforced func() bool, plans PlanChecker) zip.Handler {
|
||||
if enforced == nil {
|
||||
// No reader wired: a pure passthrough, byte-identical to no middleware at all.
|
||||
return func(c *zip.Ctx) error { return c.Next() }
|
||||
}
|
||||
return func(c *zip.Ctx) error {
|
||||
if !enforced() {
|
||||
// Dark: one flag read off a hot in-memory snapshot, no authority consulted.
|
||||
return c.Next()
|
||||
}
|
||||
path := c.Path()
|
||||
if !gated(path) {
|
||||
return c.Next() // non-/v1 surface, or an allow-listed sell/service route.
|
||||
}
|
||||
if !principal.Validated(c) {
|
||||
return c.Next() // no validated principal → the route's own auth answers.
|
||||
}
|
||||
if principal.IsSuperAdmin(c) {
|
||||
return c.Next() // platform operator bypass.
|
||||
}
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return c.Next() // no trustworthy org → fail open.
|
||||
}
|
||||
if plans == nil {
|
||||
return c.Next() // commerce cannot answer → fail open.
|
||||
}
|
||||
_, paid, err := plans.ActivePaidPlan(c.Context(), org)
|
||||
if err != nil {
|
||||
// Machinery failure (commerce/plan unreachable) — an outage must NEVER lock
|
||||
// out a subscriber. Admit and log.
|
||||
c.Log().Warn("paywall: plan unverifiable; admitting (fail open)", "org", org, "path", path, "err", err)
|
||||
return c.Next()
|
||||
}
|
||||
if paid {
|
||||
return c.Next() // live paid plan — admit.
|
||||
}
|
||||
// The ONE deny: a validated, non-admin org with a resolvable-but-planless account
|
||||
// hitting a gated product route. 402 with the upgrade hint; the handler never runs.
|
||||
c.Log().Info("paywall: no active paid plan; 402 subscription_required", "org", org, "path", path)
|
||||
return c.JSON(http.StatusPaymentRequired, map[string]any{
|
||||
"error": "subscription_required",
|
||||
"plan": "pro",
|
||||
"price": "$20/mo",
|
||||
"url": "https://cloud.hanzo.ai/plans",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gated reports whether the paywall may enforce on this request path: it must be a /v1
|
||||
// product API route AND not on the allow-list. Everything else — the SPA shell + static
|
||||
// assets on non-/v1 paths, /healthz, /readyz, the /zap WebSocket upgrade, and the whole
|
||||
// sell/service surface — is admitted so the gate can NEVER block loading the app or
|
||||
// paying for it.
|
||||
func gated(path string) bool {
|
||||
p := canonical(path)
|
||||
if !strings.HasPrefix(p, "/v1/") {
|
||||
return false // not a /v1 product API route (SPA, static, /healthz, /readyz, /zap).
|
||||
}
|
||||
return !allowlisted(p)
|
||||
}
|
||||
|
||||
// canonical folds the two ways a request path can differ from the form written in
|
||||
// the lists below while meaning the same route: ASCII case, and a trailing slash.
|
||||
// Both predicates read the folded value, so the gate decides once and cannot
|
||||
// disagree with itself.
|
||||
//
|
||||
// Case matters in BOTH directions, which is why folding — not a second list — is
|
||||
// the fix:
|
||||
//
|
||||
// - `/V1/chat/completions` used to miss the `/v1/` prefix, so gated() returned
|
||||
// false and the request skipped the paywall entirely. A gate whose bypass is
|
||||
// a shift key is not a gate.
|
||||
// - `/v1/IAM/callback` used to miss the `/v1/iam/` allow prefix, so an OAuth
|
||||
// callback would have been refused 402 — locking a user out of signing in.
|
||||
//
|
||||
// The trailing slash is the same shape of bug against the exact-match list:
|
||||
// `/v1/signin/` matched no case in allowlisted() and no allow prefix, so it
|
||||
// gated. `/v1/` itself is left alone (nothing is trimmed below the prefix), so a
|
||||
// bare version root keeps whatever the router already did with it.
|
||||
func canonical(path string) string {
|
||||
p := strings.ToLower(path)
|
||||
if len(p) > len("/v1/") {
|
||||
p = strings.TrimRight(p, "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// allowlisted reports whether a /v1 path is EXEMPT from the paywall — every route the
|
||||
// console needs to authenticate a user and let them SEE and BUY a plan, plus the
|
||||
// liveness surface. Blocking any of these would brick the path to payment (a paywall in
|
||||
// front of the pay button is catastrophic), so the list is deliberately generous.
|
||||
//
|
||||
// Traced from the console subscribe flow (console src/components/products/PlansModule.tsx
|
||||
// → src/lib/api/plans.ts): PlansApi.plans() reads GET /v1/billing/plans through the
|
||||
// per-tenant billing proxy, and checkout drives the /v1/billing/* money surface
|
||||
// (subscribe/subscriptions/balance/payment-methods/usage/invoices/credit/deposit/topup/
|
||||
// gpu/spend-alerts/payment-config). Auth is /v1/signin, /v1/signout, /v1/get-account and
|
||||
// the /v1/iam/* login/OAuth/OIDC callbacks; /v1/models is the model catalog the shell
|
||||
// reads; /v1/entitlements is the product projection the shell renders the upgrade UI from.
|
||||
// Its argument is already canonical() — gated() is the only caller, so every entry
|
||||
// below is written in lower case with no trailing slash and compared against a
|
||||
// value in that same form.
|
||||
func allowlisted(path string) bool {
|
||||
// Liveness/health — never gated (mirrors DefaultPrice's probe carve-out).
|
||||
if path == "/v1/health" || strings.HasSuffix(path, "/health") {
|
||||
return true
|
||||
}
|
||||
// Exact single-route exemptions.
|
||||
switch path {
|
||||
case "/v1/signin", // auth: session bootstrap (console posts the OAuth code here).
|
||||
"/v1/signout",
|
||||
"/v1/get-account", // auth: the account read AuthGate loads before anything else.
|
||||
"/v1/plans", // plans catalog root (@hanzo/plans subsystem).
|
||||
"/v1/models", // OpenAI-compatible model catalog (discovery).
|
||||
"/v1/entitlements": // the shell's product projection — renders the upgrade UI.
|
||||
return true
|
||||
}
|
||||
// Prefix exemptions (sub-trees).
|
||||
for _, p := range allowPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allowPrefixes are the /v1 sub-trees the paywall never gates.
|
||||
var allowPrefixes = []string{
|
||||
"/v1/billing/", // the whole commerce billing + subscribe money surface.
|
||||
"/v1/iam/", // IAM login / OAuth token exchange / .well-known OIDC discovery.
|
||||
"/v1/plans/", // plans catalog sub-routes (resolve, entitlements, cloud, gpu, …).
|
||||
"/v1/models/", // model retrieve (/v1/models/:id).
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
package routers
|
||||
|
||||
// Unit tests for the subscription paywall, driven end-to-end through the real zip
|
||||
// stack against a fake PlanChecker whose verdict each test controls. Principal state is
|
||||
// set exactly as the identity boundary would leave it: X-User-Id = validated principal,
|
||||
// X-Org-Id = the org, X-User-IsAdmin = platform super-admin.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// fakePlans is a controllable PlanChecker. calls counts how often the paywall actually
|
||||
// consulted commerce, so a test can prove an allow-listed / admin / dark-ship request
|
||||
// short-circuits BEFORE the plan read.
|
||||
type fakePlans struct {
|
||||
tier string
|
||||
paid bool
|
||||
err error
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakePlans) ActivePaidPlan(_ context.Context, _ string) (string, bool, error) {
|
||||
f.calls.Add(1)
|
||||
return f.tier, f.paid, f.err
|
||||
}
|
||||
|
||||
// newApp wires Paywall(enforced, plans) ahead of a catch-all that records whether the
|
||||
// handler ran and answers 200 — so a 402/deny is observable as "handler did not run".
|
||||
func newApp(enforced bool, plans PlanChecker, ran *atomic.Bool) *zip.App {
|
||||
app := zip.New(zip.Config{})
|
||||
// The middleware reads enforcement per request; the tests pin it to a constant.
|
||||
app.Use(Paywall(func() bool { return enforced }, plans))
|
||||
app.Get("/*", func(c *zip.Ctx) error {
|
||||
ran.Store(true)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
// principal header sets, exactly as SanitizeIdentity leaves them.
|
||||
var (
|
||||
validated = map[string]string{"X-User-Id": "alice", "X-Org-Id": "acme"}
|
||||
superUser = map[string]string{"X-User-Id": "z", "X-Org-Id": "admin", "X-User-IsAdmin": "true"}
|
||||
anonymous = map[string]string{} // no X-User-Id → not a validated principal.
|
||||
)
|
||||
|
||||
func do(t *testing.T, app *zip.App, path string, headers map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s: %v", path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func bodyOf(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// (1) A validated org WITH an active paid plan reaches a gated product route → 200.
|
||||
func TestPaywall_ActivePlanPasses(t *testing.T) {
|
||||
f := &fakePlans{tier: "pro", paid: true}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", validated)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("handler did not run for a paid org")
|
||||
}
|
||||
if f.calls.Load() != 1 {
|
||||
t.Fatalf("ActivePaidPlan calls = %d, want 1", f.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// (2) A validated, non-admin org with NO paid plan on a gated route → 402
|
||||
// subscription_required, exact body shape, handler NOT run.
|
||||
func TestPaywall_NoPlan402(t *testing.T) {
|
||||
f := &fakePlans{paid: false}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", validated)
|
||||
if resp.StatusCode != http.StatusPaymentRequired {
|
||||
t.Fatalf("status = %d, want 402", resp.StatusCode)
|
||||
}
|
||||
if ran.Load() {
|
||||
t.Fatal("handler ran despite the 402")
|
||||
}
|
||||
body := bodyOf(t, resp)
|
||||
for _, want := range []string{
|
||||
`"error":"subscription_required"`,
|
||||
`"plan":"pro"`,
|
||||
`"price":"$20/mo"`,
|
||||
`"url":"https://cloud.hanzo.ai/plans"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("402 body %q missing %q", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Every allow-listed sell/service route passes WITH no plan — and never even
|
||||
// consults commerce (short-circuits before the plan read), so a paywall in front of the
|
||||
// pay button can never happen.
|
||||
func TestPaywall_AllowlistedPassWithNoPlan(t *testing.T) {
|
||||
paths := []string{
|
||||
"/v1/signin", "/v1/signout", "/v1/get-account",
|
||||
"/v1/billing/plans", "/v1/billing/subscriptions", "/v1/billing/balance",
|
||||
"/v1/billing/payment-methods", "/v1/billing/usage",
|
||||
"/v1/plans", "/v1/plans/resolve/pro",
|
||||
"/v1/models", "/v1/models/zen-1",
|
||||
"/v1/iam/login", "/v1/iam/oauth/token",
|
||||
"/v1/entitlements",
|
||||
"/v1/health", "/v1/ai/health", "/v1/kms/health",
|
||||
}
|
||||
for _, p := range paths {
|
||||
f := &fakePlans{paid: false}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
resp := do(t, app, p, validated)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want 200 (allow-listed)", p, resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Errorf("%s: handler did not run (should be exempt)", p)
|
||||
}
|
||||
if f.calls.Load() != 0 {
|
||||
t.Errorf("%s: consulted commerce %d times, want 0 (short-circuit)", p, f.calls.Load())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) A platform super-admin bypasses the gate even with no paid plan.
|
||||
func TestPaywall_AdminBypasses(t *testing.T) {
|
||||
f := &fakePlans{paid: false}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", superUser)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (admin bypass)", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("handler did not run for a super-admin")
|
||||
}
|
||||
if f.calls.Load() != 0 {
|
||||
t.Fatalf("admin consulted commerce %d times, want 0 (bypass before the read)", f.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// (5) PAYWALL_ENFORCED=false → dark ship: EVERYTHING passes, and commerce is never
|
||||
// consulted, so there is zero behavior change until an owner flips the flag.
|
||||
func TestPaywall_DisabledPassesEverything(t *testing.T) {
|
||||
f := &fakePlans{paid: false}
|
||||
var ran atomic.Bool
|
||||
app := newApp(false, f, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", validated)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (dark ship)", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("handler did not run while paywall disabled")
|
||||
}
|
||||
if f.calls.Load() != 0 {
|
||||
t.Fatalf("disabled paywall consulted commerce %d times, want 0", f.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// (6) A commerce/plan machinery error fails OPEN — an outage never locks out a
|
||||
// subscriber.
|
||||
func TestPaywall_FailOpenOnError(t *testing.T) {
|
||||
f := &fakePlans{err: io.ErrUnexpectedEOF}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", validated)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (fail open on error)", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("handler did not run on a plan-read error (should fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
// (7) A nil PlanChecker (commerce cannot answer — split-deploy / disabled stub) fails
|
||||
// OPEN.
|
||||
func TestPaywall_NilCheckerFailOpen(t *testing.T) {
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, nil, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", validated)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (nil checker → fail open)", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("handler did not run with a nil PlanChecker")
|
||||
}
|
||||
}
|
||||
|
||||
// (8) An anonymous request (no validated principal) is admitted — the route's own auth
|
||||
// answers it; a 402 upgrade prompt is meaningless to someone not signed in, and the org
|
||||
// is untrusted anyway. Commerce is never consulted on an unvalidated org.
|
||||
func TestPaywall_AnonymousPasses(t *testing.T) {
|
||||
f := &fakePlans{paid: false}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
resp := do(t, app, "/v1/agents/list", anonymous)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (anonymous → route auth answers)", resp.StatusCode)
|
||||
}
|
||||
if f.calls.Load() != 0 {
|
||||
t.Fatalf("anonymous consulted commerce %d times, want 0", f.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// (9) A non-/v1 path (the SPA shell / static asset) is never gated, so the app that
|
||||
// renders the upgrade prompt always loads — even for a planless org.
|
||||
func TestPaywall_NonV1PathPasses(t *testing.T) {
|
||||
f := &fakePlans{paid: false}
|
||||
var ran atomic.Bool
|
||||
app := newApp(true, f, &ran)
|
||||
|
||||
for _, p := range []string{"/", "/billing", "/plans", "/assets/app.js"} {
|
||||
f.calls.Store(0)
|
||||
ran.Store(false)
|
||||
resp := do(t, app, p, validated)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want 200 (non-/v1 never gated)", p, resp.StatusCode)
|
||||
}
|
||||
if f.calls.Load() != 0 {
|
||||
t.Errorf("%s: consulted commerce %d times, want 0", p, f.calls.Load())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A gate whose bypass is a shift key is not a gate: before canonical(), gated()
|
||||
// tested strings.HasPrefix(path, "/v1/") against the raw path, so any casing of
|
||||
// the version segment skipped the paywall and reached the handler unpaid.
|
||||
func TestGated_CaseFoldedSoCasingCannotBypass(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/chat/completions",
|
||||
"/V1/chat/completions",
|
||||
"/V1/CHAT/COMPLETIONS",
|
||||
"/v1/analytics/events",
|
||||
"/V1/analytics/events",
|
||||
} {
|
||||
if !gated(path) {
|
||||
t.Errorf("gated(%q) = false, want true — this path skips the paywall", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror of the same bug, in the direction that locks a paying user OUT: an
|
||||
// allow-listed route in other casing missed the allow list and would have been
|
||||
// refused 402 — on the OAuth callback, of all routes.
|
||||
func TestGated_CaseFoldedSoAllowlistStillMatches(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/iam/callback",
|
||||
"/V1/iam/callback",
|
||||
"/v1/IAM/callback",
|
||||
"/V1/BILLING/subscribe",
|
||||
"/V1/signin",
|
||||
} {
|
||||
if gated(path) {
|
||||
t.Errorf("gated(%q) = true, want false — 402 on the sell/service surface", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A trailing slash means the same route. It used to match neither the exact-match
|
||||
// list nor any allow prefix, so /v1/signin/ was paywalled: a 402 in front of sign-in.
|
||||
func TestGated_TrailingSlashDoesNotLockOut(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/signin/",
|
||||
"/v1/signout/",
|
||||
"/v1/get-account/",
|
||||
"/v1/entitlements/",
|
||||
"/v1/plans/",
|
||||
"/v1/models/",
|
||||
"/v1/health/",
|
||||
"/v1/billing/subscribe/",
|
||||
} {
|
||||
if gated(path) {
|
||||
t.Errorf("gated(%q) = true, want false — trailing slash must not gate a sell/service route", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folding must not open a hole: a gated product route stays gated with a trailing
|
||||
// slash, and the bare version root is left exactly as the router had it.
|
||||
func TestGated_TrailingSlashDoesNotOpenAHole(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/v1/chat/completions/",
|
||||
"/V1/chat/completions/",
|
||||
"/v1/analytics/events/",
|
||||
} {
|
||||
if !gated(path) {
|
||||
t.Errorf("gated(%q) = false, want true — trailing slash must not bypass", path)
|
||||
}
|
||||
}
|
||||
if got := canonical("/v1/"); got != "/v1/" {
|
||||
t.Errorf("canonical(%q) = %q, want it untouched", "/v1/", got)
|
||||
}
|
||||
for _, path := range []string{"/healthz", "/readyz", "/zap", "/", "/assets/app.js"} {
|
||||
if gated(path) {
|
||||
t.Errorf("gated(%q) = true, want false — non-/v1 surface", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforcement is read per request, so flipping the cockpit switch takes effect on the
|
||||
// NEXT request against an already-mounted app. Before this, `enforced` was evaluated
|
||||
// once at mount and the only way to change it was to restart the binary — which is why
|
||||
// the switch registered in the cockpit governed nothing.
|
||||
func TestPaywall_EnforcementIsReadPerRequest(t *testing.T) {
|
||||
var enforced atomic.Bool // starts false: dark
|
||||
var ran atomic.Bool
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(Paywall(enforced.Load, &fakePlans{paid: false}))
|
||||
app.Get("/*", func(c *zip.Ctx) error {
|
||||
ran.Store(true)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
|
||||
ran.Store(false)
|
||||
if resp := do(t, app, "/v1/chat/completions", validated); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dark: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("dark: handler did not run")
|
||||
}
|
||||
|
||||
enforced.Store(true) // the owner flips it in admin.hanzo.ai
|
||||
|
||||
ran.Store(false)
|
||||
resp := do(t, app, "/v1/chat/completions", validated)
|
||||
if resp.StatusCode != http.StatusPaymentRequired {
|
||||
t.Fatalf("after flip: status = %d, want 402 — the switch did not hot-apply", resp.StatusCode)
|
||||
}
|
||||
if ran.Load() {
|
||||
t.Fatal("after flip: handler ran despite 402")
|
||||
}
|
||||
|
||||
enforced.Store(false) // and flips it back — the kill switch must restore access
|
||||
|
||||
ran.Store(false)
|
||||
if resp := do(t, app, "/v1/chat/completions", validated); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("after revert: status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("after revert: handler did not run")
|
||||
}
|
||||
}
|
||||
|
||||
// A nil reader is the dark default and must never gate.
|
||||
func TestPaywall_NilReaderPassesEverything(t *testing.T) {
|
||||
var ran atomic.Bool
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(Paywall(nil, &fakePlans{paid: false}))
|
||||
app.Get("/*", func(c *zip.Ctx) error {
|
||||
ran.Store(true)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
if resp := do(t, app, "/v1/chat/completions", validated); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if !ran.Load() {
|
||||
t.Fatal("handler did not run")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/hanzoai/cloud/internal/storagelock"
|
||||
"github.com/hanzoai/cloud/openapi"
|
||||
"github.com/hanzoai/cloud/role"
|
||||
"github.com/hanzoai/cloud/routers"
|
||||
"github.com/hanzoai/cloud/writerpin"
|
||||
"github.com/hanzoai/cloud/zapface"
|
||||
luxlog "github.com/luxfi/log"
|
||||
@@ -317,29 +316,30 @@ func Serve(specs []MountSpec, enable []string) error {
|
||||
// subsystems (notably /v1/ai/*) at 0 to avoid double-billing.
|
||||
app.Use(BillingGate(deps.Metering, DefaultPrice))
|
||||
|
||||
// Subscription paywall (task #36). Runs AFTER IdentityMiddleware (so it keys on the
|
||||
// VALIDATED principal + owner claim, never a client X-Org-Id) and beside BillingGate,
|
||||
// BEFORE MountAll so it precedes every subsystem /v1/<name>/* wildcard. DARK by
|
||||
// default: PAYWALL_ENFORCED=false makes it a pure passthrough (zero behavior change)
|
||||
// until an owner flips it. The plan read is the co-resident commerce client's OPTIONAL
|
||||
// ActivePaidPlan capability, resolved by type-assertion — a commerce build that cannot
|
||||
// answer (nil / split-deploy / disabled stub) makes the gate fail OPEN, never locking
|
||||
// a subscriber out. The sell/service surface (sign-in/billing/plans/models/health) is
|
||||
// always exempt, so the gate can never block the path to payment.
|
||||
var planChecker routers.PlanChecker
|
||||
if pc, ok := deps.Commerce.(routers.PlanChecker); ok {
|
||||
planChecker = pc
|
||||
}
|
||||
// cfg.PaywallEnforced is a BOOT-time value: flipping the paywall needs a redeploy.
|
||||
// See routers/paywall.go for why the cockpit switch cannot reach this middleware.
|
||||
// Enforcement is read PER REQUEST from the platform switch an owner flips at
|
||||
// admin.hanzo.ai, so turning the paywall on or off applies within one flag-cache
|
||||
// TTL — no redeploy, no CR edit. Before the flag engine mounts (and when it is
|
||||
// absent) Switch reports false, so PAYWALL_ENFORCED remains the floor and a
|
||||
// deployment that already sets it keeps enforcing exactly as it did.
|
||||
app.Use(routers.Paywall(func() bool {
|
||||
return cfg.PaywallEnforced || Switch(SwitchPaywallEnforced)
|
||||
}, planChecker))
|
||||
// Spend gate — the ONE "may this principal spend?" enforcement point. Runs AFTER
|
||||
// IdentityMiddleware (so it keys on the VALIDATED principal + owner claim, never a
|
||||
// client X-Org-Id) and beside BillingGate, BEFORE MountAll so it precedes every
|
||||
// subsystem /v1/<name>/* wildcard.
|
||||
//
|
||||
// It replaces routers.Paywall, which asked only "does this org hold a paid PLAN?".
|
||||
// That question has no credit leg, so enabling it would have 402'd every prepaid
|
||||
// customer — which is why it was mounted for months and never turned on. SpendGate
|
||||
// admits on subscription OR prepaid credit (cloud.Stand), read at the wallet address
|
||||
// the DEBIT writes, and applies to the billable paths only (cloud.Billable) — LLM
|
||||
// and non-LLM resource trees alike.
|
||||
//
|
||||
// DARK by default and it must stay dark until a starter-credit path exists: there is
|
||||
// none in this binary today, so enforcing would 402 every new signup on day one. See
|
||||
// middleware_spend.go.
|
||||
//
|
||||
// Enforcement is read ONLY from the platform switch — there is no env var and no
|
||||
// Config field. It used to be `cfg.PaywallEnforced || Switch(...)`, a boot-time OR
|
||||
// that could not be turned OFF from the cockpit: the kill switch was defeated by the
|
||||
// very variable that armed the gate. clients/entitlements now registers
|
||||
// paywall_enforced with NO Env fallback for the same reason (its own
|
||||
// TestSwitchesDefaultOff pins that, and was RED against the old registration).
|
||||
// One reader, one answer, and the kill switch always wins.
|
||||
app.Use(SpendGate(deps.Commerce))
|
||||
|
||||
// HIP-0106 liveness contract: every enabled subsystem answers
|
||||
// GET /v1/<name>/health uniformly, registered at the compose root before
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package cloud
|
||||
|
||||
// spend.go answers ONE question for the whole binary — MAY THIS PRINCIPAL SPEND? —
|
||||
// and it is the only place that answer is computed.
|
||||
//
|
||||
// WHY IT MOVED HERE. The answer used to live in clients/entitlements, a leaf that
|
||||
// imports this package. That is the wrong side of the dependency: the edge filters
|
||||
// serve.go mounts live in THIS package, so they could never reach it. The result was
|
||||
// three gates that each re-derived a different answer:
|
||||
//
|
||||
// - routers.Paywall (mounted app-wide) asked only "does the org hold a paid PLAN?".
|
||||
// It has no credit leg at all, so enabling it would have 402'd every prepaid
|
||||
// customer — the gate was unusable, which is why it stayed dark forever. Deleted;
|
||||
// serve.go now mounts SpendGate.
|
||||
// - middleware_billing.BillingGate (mounted app-wide) asked "is price(path) > 0?"
|
||||
// and DefaultPrice returns 0 for every path, so it never evaluated ANYTHING.
|
||||
// - clients/entitlements.RequireProduct had the RIGHT answer and was mounted on
|
||||
// no route at all.
|
||||
//
|
||||
// So the binary shipped three paywalls and enforced none. One predicate, in the one
|
||||
// package every gate can import, is the fix.
|
||||
//
|
||||
// TWO WAYS TO PAY, ONE ANSWER. The rule is "an active subscription OR a positive
|
||||
// prepaid balance". Those are two INDEPENDENT facts held by two INDEPENDENT
|
||||
// authorities, and Stand is the single place they compose:
|
||||
//
|
||||
// - SUBSCRIPTION — resolved by the CALLER, passed in as a Licence. The two callers
|
||||
// ask genuinely different questions of commerce ("is org X licensed for the
|
||||
// studio product?" vs "does org X hold any live paid plan?"), so the licence leg
|
||||
// is an ARGUMENT, not a call made here. Composing it is this file's job; asking
|
||||
// it is not.
|
||||
// - PREPAID CREDIT — the caller's wallet on the native finance ledger, read here
|
||||
// because there is exactly one right way to read it (see below).
|
||||
//
|
||||
// THE ADDRESS IS LOAD-BEARING. A money gate that reads a different wallet than the
|
||||
// debit writes is the bug this codebase has already shipped three times, every time
|
||||
// by keying the ORG POOL — see clients/principal/wallet.go, which lists them. Read on
|
||||
// the pool, this predicate would admit every member of the shared signup org for as
|
||||
// long as the platform's own pool is funded, which is a total bypass and is precisely
|
||||
// the live free-inference hole (apps/zen.go still gates the pool). So the credit leg
|
||||
// reads principal.WalletOf's address and nothing else.
|
||||
//
|
||||
// CREDIT IS EXACT. finance.Balance returns money.Amount — 18-decimal atto-USD over
|
||||
// big.Int. The admit boundary is Sign() > 0: zero denies, ONE ATTO admits. No
|
||||
// threshold, no cent-flooring, no float anywhere on this path.
|
||||
//
|
||||
// PROOF, NOT ABSENCE. A caller is Unpaid only when BOTH authorities ANSWERED and both
|
||||
// said no. If either could not answer, the standing is Unknown — this file never
|
||||
// converts "the oracle is down" into "this customer has not paid". What to DO about
|
||||
// an Unknown is enforcement's decision (middleware_spend.go), not the decide's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
)
|
||||
|
||||
// creditUnit is the asset the prepaid wallet is denominated in. One asset ships
|
||||
// (USD, 18-decimal-exact); finance selects the ledger file from it.
|
||||
const creditUnit = "usd"
|
||||
|
||||
// ── the subscription leg (supplied by the caller) ───────────────────────────────
|
||||
|
||||
// Licence is a subscription authority's ANSWER about one caller, already resolved.
|
||||
// The zero value is LicenceUnknown, which is the honest default: a question that
|
||||
// could not be asked has no answer, and "no answer" is never "not licensed".
|
||||
type Licence uint8
|
||||
|
||||
const (
|
||||
// LicenceUnknown — the authority could not answer (commerce absent, query
|
||||
// failed, nil result). NOT a statement about the caller.
|
||||
LicenceUnknown Licence = iota
|
||||
// LicenceNone — the authority answered: no live subscription.
|
||||
LicenceNone
|
||||
// LicenceActive — the authority answered: a live subscription.
|
||||
LicenceActive
|
||||
)
|
||||
|
||||
// ── the answer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Standing is a caller's resolved commercial standing. The zero value is Unknown,
|
||||
// which is the honest default: a question not yet asked has no answer, and "no
|
||||
// answer" is never "has not paid".
|
||||
type Standing uint8
|
||||
|
||||
const (
|
||||
// Unknown — at least one authority could not answer (commerce unreachable,
|
||||
// ledger unreadable, org unresolvable). NOT a statement about the caller.
|
||||
Unknown Standing = iota
|
||||
// Unpaid — BOTH authorities answered and both said no: no subscription and no
|
||||
// credit. The only proven refusal.
|
||||
Unpaid
|
||||
// Subscribed — a live subscription admits the caller.
|
||||
Subscribed
|
||||
// Funded — the wallet holds a positive prepaid balance to burn down.
|
||||
Funded
|
||||
)
|
||||
|
||||
// Admits reports whether this standing lets a request through on its own merits.
|
||||
// Unknown does NOT admit here — it is not an admit, it is an absence, and the
|
||||
// posture that resolves it is enforcement's, deliberately not hidden inside this
|
||||
// predicate.
|
||||
func (s Standing) Admits() bool { return s == Subscribed || s == Funded }
|
||||
|
||||
// String renders the standing for logs.
|
||||
func (s Standing) String() string {
|
||||
switch s {
|
||||
case Unpaid:
|
||||
return "unpaid"
|
||||
case Subscribed:
|
||||
return "subscribed"
|
||||
case Funded:
|
||||
return "funded"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Stand composes the two independent legs into the one answer.
|
||||
//
|
||||
// lic — the subscription authority's already-resolved answer.
|
||||
// w — the money address the credit leg reads (principal.WalletOf).
|
||||
//
|
||||
// The legs are evaluated cheapest-decisive-first: a licensed caller never touches
|
||||
// the ledger. A caller with no subscription always does — that is the pay-as-you-go
|
||||
// path, and it is the common case for a prepaid customer.
|
||||
func Stand(ctx context.Context, lic Licence, w principal.Wallet) Standing {
|
||||
if lic == LicenceActive {
|
||||
return Subscribed
|
||||
}
|
||||
creditOK, funded := creditIn(ctx, w)
|
||||
if creditOK && funded {
|
||||
return Funded
|
||||
}
|
||||
if lic == LicenceNone && creditOK {
|
||||
// Both authorities answered; both said no. This — and only this — is proof.
|
||||
return Unpaid
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
// creditIn reads the caller's prepaid balance from the native finance ledger and
|
||||
// reports whether it is positive. ok is false when the ledger is not published
|
||||
// (split deploy / money layer not co-resident), when the request carries no wallet,
|
||||
// or when the read FAILS — "a balance that cannot be read is unknown, never zero"
|
||||
// (clients/finance.Balance). A zero balance IS an answer: (true, false).
|
||||
//
|
||||
// The read is against the LIVE books (test=false). Sandbox money must never buy
|
||||
// live product access.
|
||||
func creditIn(ctx context.Context, w principal.Wallet) (ok, funded bool) {
|
||||
if w.Account == "" {
|
||||
return false, false
|
||||
}
|
||||
fin := finance.Current()
|
||||
if fin == nil {
|
||||
return false, false
|
||||
}
|
||||
bal, err := fin.Balance(ctx, w.Ledger, w.Account, creditUnit, false)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return true, bal.Sign() > 0
|
||||
}
|
||||
|
||||
// ── billable(path) — split OUT of price(path) ───────────────────────────────────
|
||||
|
||||
// Billable reports whether a request CONSUMES resource somebody has to pay a
|
||||
// provider for, and therefore requires standing before it runs.
|
||||
//
|
||||
// THIS IS THE BUG THE SPLIT EXISTS TO KILL. Authorization and pricing were one
|
||||
// int64: DefaultPrice returned the edge charge, and BillingGate read `cents <= 0` as
|
||||
// "do not gate". Every LLM path prices at 0 there — deliberately, because the ai and
|
||||
// zen subsystems meter their own token costs and an edge charge would double-bill —
|
||||
// so "we charge nothing HERE" silently meant "we authorize NOTHING here", and the
|
||||
// same fusion made every resource kind an operator prices at 0 un-gated as well
|
||||
// (ResourceMeter.Gate: `costCents <= 0 -> nil`). One int64 answering two questions is
|
||||
// why the LLM leak and the non-LLM gap are a single bug. They are two questions now:
|
||||
// Billable says WHETHER standing is required, DefaultPrice says WHAT the edge charges.
|
||||
// Pricing something at zero can no longer un-authorize it.
|
||||
//
|
||||
// READS ARE NEVER BILLABLE. GET/HEAD/OPTIONS pass unconditionally — gating reads
|
||||
// already caused one outage, a balance view that 402s is unusable, and no read this
|
||||
// binary serves calls a paid provider.
|
||||
//
|
||||
// The path sets are MEASURED, not invented:
|
||||
// - inference — the exact paths zen's Claim owns (zen@v1.4.2 proxy.go) plus ai's
|
||||
// own tree. These are the free-inference hole.
|
||||
// - meteredTrees — the subsystems that construct a ResourceMeter (the non-LLM
|
||||
// auth-not-balance gap). /v1/commerce/ and /v1/o11y/ are in BillingGate's
|
||||
// selfMeteredPrefixes but are deliberately NOT here: one is the pay path itself
|
||||
// and the other is telemetry ingest, and neither spends a provider's money.
|
||||
func Billable(method, path string) bool {
|
||||
switch method {
|
||||
case "GET", "HEAD", "OPTIONS":
|
||||
return false
|
||||
}
|
||||
if Reachable(path) {
|
||||
return false // the path to payment is never gated. Ever.
|
||||
}
|
||||
if inference[path] {
|
||||
return true
|
||||
}
|
||||
for _, t := range meteredTrees {
|
||||
if path == strings.TrimSuffix(t, "/") || strings.HasPrefix(path, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// inference is the exact set of bare completion endpoints. zen's Claim owns
|
||||
// /v1/messages, /v1/chat/completions, /v1/chat and /v1/completions (zen proxy.go);
|
||||
// /v1/embeddings and /v1/responses fall through to ai's catch-all. Both families
|
||||
// call a paid upstream, so both need standing regardless of which one serves.
|
||||
var inference = map[string]bool{
|
||||
"/v1/messages": true,
|
||||
"/v1/chat/completions": true,
|
||||
"/v1/chat": true,
|
||||
"/v1/completions": true,
|
||||
"/v1/embeddings": true,
|
||||
"/v1/responses": true,
|
||||
}
|
||||
|
||||
// meteredTrees are the subsystem trees whose handlers debit a ledger — every
|
||||
// NewResourceMeter construction site, plus ai's own tree. Written as a root WITH its
|
||||
// trailing slash; the bare root matches too.
|
||||
var meteredTrees = []string{
|
||||
"/v1/ai/", // LLM token costs (ai self-meters).
|
||||
"/v1/agents/", // per-run agent fee.
|
||||
"/v1/agent/", // the agent orchestrator's round.
|
||||
"/v1/mcp/", // per-tool dispatch.
|
||||
"/v1/functions/", // serverless invoke.
|
||||
"/v1/s3/", // object-storage data plane.
|
||||
"/v1/storage/", // clients/storage NewResourceMeter(deps, "s3").
|
||||
"/v1/ml/", // clients/ml NewResourceMeter(deps, "compute").
|
||||
"/v1/visor/", // clients/visor NewResourceMeter(deps, "compute").
|
||||
"/v1/security/", // clients/security scan fee.
|
||||
"/v1/projects/", // clients/projects hosting fee.
|
||||
"/v1/cloudflare/", // clients/cloudflare provisioning.
|
||||
}
|
||||
|
||||
// ── the invariant: the path to payment is never gated ───────────────────────────
|
||||
|
||||
// Reachable reports whether a path must be served REGARDLESS of standing.
|
||||
//
|
||||
// This is not a scope selector — which routes a gate guards is decided by where it is
|
||||
// applied. It is a SAFETY PROPERTY: a customer who has not yet paid must still be
|
||||
// able to reach the paths that let them pay, and a lapsed one must be able to cure
|
||||
// their own lapse. Gate those and you deadlock every prospect and every lapsed
|
||||
// customer at once — a self-inflicted total revenue stop that looks like success in a
|
||||
// naive test, because everything returns 402. So the list lives INSIDE the predicate:
|
||||
// no future wiring mistake can gate the pay path, whatever it wraps.
|
||||
//
|
||||
// Gating too little is a revenue leak we fix next week. Gating too much is an outage.
|
||||
// The list is therefore deliberately generous, and anything ambiguous belongs on it.
|
||||
//
|
||||
// Traced from the console subscribe flow (console src/components/products/
|
||||
// PlansModule.tsx -> src/lib/api/plans.ts): PlansApi.plans() reads /v1/billing/plans
|
||||
// through the per-tenant billing proxy, and checkout drives the /v1/billing/* money
|
||||
// surface — which also carries the INBOUND PROVIDER WEBHOOKS at
|
||||
// /v1/billing/webhooks/:provider. Gating an inbound payment webhook loses payments
|
||||
// outright, so that prefix is load-bearing twice over.
|
||||
func Reachable(path string) bool {
|
||||
// Liveness / readiness / metrics — a gate must never hide whether the process is
|
||||
// up, or an incident becomes invisible at exactly the wrong moment.
|
||||
switch path {
|
||||
case "/health", "/healthz", "/readyz", "/livez", "/metrics":
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(path, "/health") {
|
||||
return true // the per-subsystem HIP-0106 probe, /v1/<name>/health.
|
||||
}
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
return true // the SPA shell + static assets that render the paywall screen itself.
|
||||
}
|
||||
switch path {
|
||||
case "/v1/signin", // auth: session bootstrap (the console posts the OAuth code here).
|
||||
"/v1/signout",
|
||||
"/v1/get-account", // auth: the account read AuthGate loads before anything else.
|
||||
"/v1/entitlements": // the paywall's OWN projection — what the shell renders upgrade UI from.
|
||||
return true
|
||||
}
|
||||
for _, sub := range reachableTrees {
|
||||
// A sub-tree covers its own ROOT as well as everything under it. Matching only
|
||||
// "<root>/" is how a paywall ends up refusing the exact URL its own 402 points
|
||||
// at — one missing slash and the cure is behind the gate. One rule, both forms.
|
||||
if path == strings.TrimSuffix(sub, "/") || strings.HasPrefix(path, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reachableTrees are the /v1 sub-trees a spend gate may never refuse. Every entry is
|
||||
// written as a root WITH its trailing slash and covers the bare root too.
|
||||
var reachableTrees = []string{
|
||||
"/v1/billing/", // the whole money surface: subscribe, top up, AND the inbound provider webhooks.
|
||||
"/v1/commerce/", // the co-resident commerce plane the checkout and tenant reads drive.
|
||||
"/v1/iam/", // IAM login / OAuth token exchange / .well-known OIDC discovery.
|
||||
"/v1/account/", // the account surface the shell renders before any purchase decision.
|
||||
"/v1/admin/", // platform sudo — including the cockpit that holds this gate's kill switch.
|
||||
"/v1/plans/", // the plans catalog — WHAT to buy (@hanzo/plans) — and its sub-routes.
|
||||
"/v1/models/", // the model catalog the shell reads for discovery, and /v1/models/:id.
|
||||
"/v1/orgs/", // org read + switch: the shell must resolve which org it is buying for.
|
||||
"/v1/waitlist/", // admission's join API — an un-admitted user must still reach it.
|
||||
"/v1/flags/", // the guard's public mode read; also how the kill switch is observed.
|
||||
"/v1/entitlements/", // per-org enablement reads/writes that sit beside the projection.
|
||||
}
|
||||
@@ -23,6 +23,13 @@ import "sync/atomic"
|
||||
// about its key is worse than no switch.
|
||||
const SwitchPaywallEnforced = "paywall_enforced"
|
||||
|
||||
// SwitchPaywallStrict is the posture on an UNRESOLVABLE standing. OFF (the
|
||||
// default) = availability: an authority we cannot reach never refuses a customer.
|
||||
// ON = revenue: an unresolvable standing refuses. It lives beside its sibling for
|
||||
// the same reason — SpendGate (this package) and RequireProduct (clients/
|
||||
// entitlements, which also REGISTERS it) must name one string.
|
||||
const SwitchPaywallStrict = "paywall_strict"
|
||||
|
||||
// switchReader is the flag engine's Bool, installed by clients/flags.Mount.
|
||||
// atomic because Mount runs during boot while requests may already be served.
|
||||
var switchReader atomic.Pointer[func(string) bool]
|
||||
|
||||
Reference in New Issue
Block a user