money: credit is an admin decision, so the automatic grant goes

The starter grant minted $5 into a wallet from middleware, on first credential
contact, with no human in the loop. Credit into an org is an ADMIN decision --
made deliberately, through the admin surface, against an auditable ledger --
so an automatic path that creates money is not a feature to fix but a mechanism
to remove.

DELETED RATHER THAN SWITCHED OFF. A disabled money-mint is one flag away from
an enabled one, and the flag is the kind of thing a later reader flips to
"unblock" something. There is no starter code left to re-enable: the middleware,
its mount in serve.go, the cross-process plane op (finance_starter / StarterIn /
Granted) that let a non-ledger binary ask for it, and their tests are gone.

Note this also removes the shared-signup-org exclusion that lived in the gate.
It was sound anti-abuse for a grant that no longer exists, and keeping half a
mechanism to guard the other half is how dead code survives.

The paywall consequence is deliberate and is NOT taken here: SpendGate stays
behind its kill switch. With no automatic funding, enforcing it 402s every new
account from its first request -- an honest paywall, and a product decision that
deserves its own change rather than arriving as a side effect of this one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-08-03 17:30:05 -07:00
parent be8e99b079
commit 41b23f124a
11 changed files with 16 additions and 1019 deletions
-51
View File
@@ -9,7 +9,6 @@ import (
"github.com/hanzoai/cloud"
financeclient "github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/money"
"github.com/zap-proto/zip"
)
@@ -100,56 +99,6 @@ func planeBalance(ctx context.Context, in *plane.BalanceIn) (*plane.Balance, err
return &plane.Balance{Amount: plane.Amount(bal.Unwrap())}, nil
}
// The welcome grant, published for the same reason the balance read is: the ledger
// has one writer and it lives here.
//
// StarterGrant is middleware on EVERY app's chain, and it used to bail the moment
// it found no local ledger — which, once apps became their own binaries, is every
// process but this one. So a new account was never funded: it reached tracker or
// billing, the grant looked for a ledger that was one socket away, and returned
// silently. An org that should have started with the welcome credit started broke,
// and the paywall refused it correctly for a reason nobody had chosen.
//
// The idempotency key is the ACCOUNT and nothing else, so asking twice — from two
// processes, after a restart, or concurrently — grants once. That property lives in
// finance, which dedups inside the same transaction as the insert; this op only
// carries the question across.
func exposeStarter() {
zip.Post[plane.StarterIn, plane.Granted](cloud.Plane(), "/finance/starter", planeStarter,
zip.WithOperationID(plane.FinanceStarter),
zip.WithSummary("Issue the opening credit for an org, once"))
}
// Grants a new account its opening welcome credit and answers the amount granted.
//
// GRANTED ONCE, whoever asks. The idempotency key is the ACCOUNT and nothing
// else, so asking twice — from two processes, after a restart, or concurrently —
// grants exactly once; that property lives in the ledger, which dedups inside the
// same transaction as the insert, and this op only carries the question across.
//
// The org is the CALLER'S and can never be named in the input; an empty subject
// grants to the org's own account. It is published because the grant runs as
// middleware on EVERY app's chain while the ledger has one writer and lives here
// — a grant that could not reach it left new orgs unfunded and correctly
// paywalled for a reason nobody had chosen.
//
// A named handler, not a closure, so zipdoc can lift this prose into the registry.
func planeStarter(ctx context.Context, in *plane.StarterIn) (*plane.Granted, error) {
org, err := callerOrg(ctx, "starter")
if err != nil {
return nil, err
}
subject := in.Subject
if subject == "" {
subject = org
}
cents, err := cloud.GrantStarter(ctx, org, subject)
if err != nil {
return nil, fmt.Errorf("starter: %w", err)
}
return &plane.Granted{Amount: plane.Amount(money.FromUSD(cents))}, nil
}
// usageReadLimit matches what the co-resident reader asks for, so the page a
// customer sees does not change with which process answered.
const usageReadLimit = 2000
-1
View File
@@ -176,7 +176,6 @@ func Mount(app *zip.App, deps cloud.Deps) error {
exposeBalance()
exposeMeter(deps.Metering)
exposeCredit()
exposeStarter()
exposeUsage()
exposeTxns()
exposeScopeRules()
-291
View File
@@ -1,291 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerce
// starter_test.go proves the one claim a starter grant lives or dies on: the money
// lands at the address the SPEND GATE READS. A grant that credits a wallet the gate
// does not read is worse than no grant — the account looks funded and still 402s, and
// that exact bug has shipped twice before (clients/principal/wallet.go names both).
//
// So these tests never assert against the balance the grant itself reports. They
// re-derive the gate's address the way the gate does — account.Payer(...).Subject(),
// the ONE rule — and read THAT. If the two ever diverge, the read returns 0 and every
// test here fails.
//
// It lives in package apps because this is where the real chain is assembled: the
// cloud ledger adapter (ledger{}) that translates (Org, Subject) into a finance
// posting. Testing cloud.EnsureStarterCredit against a stub ledger would prove only
// that the stub agrees with itself. The middleware's own behaviour — the hot-path
// cache, which principals are eligible — is proven in middleware_starter_test.go,
// where a request context is cheap to build.
import (
"context"
"sync"
"testing"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/types"
commercecredit "github.com/hanzoai/commerce/billing/credit"
"github.com/hanzoai/commerce/billing/creditledger"
)
// wireLedger assembles the production money chain over a temp data dir: a real
// finance ledger, published as the process-wide client, with the real cloud adapter
// injected as commerce's credit seam. This is the same pair build.go/apps wire at
// boot — no fakes anywhere in the path under test.
func wireLedger(t *testing.T) finance.Client {
t.Helper()
fin := finance.New(t.TempDir())
finance.Publish(fin)
creditledger.Set(ledger{})
t.Cleanup(func() {
creditledger.Set(nil)
finance.Publish(nil)
})
return fin
}
// starterWalletOf builds the address a credential resolves to, by the SAME rule the gate
// uses. Owner is the home org (the ledger), name the person. No `billing_account`
// claim is supplied because IAM v2 mints none, so Payer takes the fallback — which is
// precisely the production shape.
func starterWalletOf(owner, name string) principal.Wallet {
return principal.Wallet{
Ledger: owner,
Account: account.Payer(account.Credential{Owner: owner, Name: name}).Subject(),
}
}
// gateBalance reads a principal's balance EXACTLY as the spend gate does: resolve the
// payer with account.Payer (what principal.WalletOf calls), then read that subject in
// the home org's ledger (what build.go's BalanceReader and metering's fetchAvailable
// both call).
func gateBalance(t *testing.T, fin finance.Client, owner, name string) int64 {
t.Helper()
w := starterWalletOf(owner, name)
bal, err := fin.Balance(context.Background(), w.Ledger, w.Account, "usd", false)
if err != nil {
t.Fatalf("gate balance read (owner=%q name=%q subject=%q): %v", owner, name, w.Account, err)
}
return bal.Cents()
}
// TestStarterCredit_LandsAtTheAddressTheGateReads is THE test. A new account is
// granted, and the balance is then read through the gate's own address rule — not the
// grant's return value. They must agree, or the grant funds a wallet nobody spends
// from.
func TestStarterCredit_LandsAtTheAddressTheGateReads(t *testing.T) {
fin := wireLedger(t)
if got := gateBalance(t, fin, "acme", "alice"); got != 0 {
t.Fatalf("a brand-new org must start at zero, got %d cents", got)
}
reported, err := cloud.EnsureStarterCredit(context.Background(), starterWalletOf("acme", "alice"))
if err != nil {
t.Fatalf("EnsureStarterCredit: %v", err)
}
if got := gateBalance(t, fin, "acme", "alice"); got != commercecredit.StarterCreditCents {
t.Fatalf("the GATE reads %d cents at acme, want %d — the grant landed at an address the gate does not read",
got, commercecredit.StarterCreditCents)
}
// A second member of the same org reads the SAME pool: one grant funds the tenant,
// not each employee. This is what makes "once per account" mean once per org.
if got := gateBalance(t, fin, "acme", "bob"); got != commercecredit.StarterCreditCents {
t.Fatalf("a second member reads %d cents, want the same pool %d", got, commercecredit.StarterCreditCents)
}
if reported != commercecredit.StarterCreditCents {
t.Fatalf("reported balance %d, want %d", reported, commercecredit.StarterCreditCents)
}
}
// TestStarterCredit_AmountIsServerAuthoritative pins the granted amount to the shared
// constant. EnsureStarterCredit takes no amount and no request body, so no client
// field can reach it — this catches drift between the constant and what lands.
func TestStarterCredit_AmountIsServerAuthoritative(t *testing.T) {
fin := wireLedger(t)
if _, err := cloud.EnsureStarterCredit(context.Background(), starterWalletOf("acme", "alice")); err != nil {
t.Fatalf("EnsureStarterCredit: %v", err)
}
if got := gateBalance(t, fin, "acme", "alice"); got != 500 {
t.Fatalf("granted %d cents, want exactly 500 ($5.00, the one canonical amount)", got)
}
}
// TestStarterCredit_RetryGrantsOnce covers the sequential replays: a retried request,
// a re-login, a process restart that empties the hot-path cache. Every one derives the
// same address-keyed ref, so the ledger credits once.
func TestStarterCredit_RetryGrantsOnce(t *testing.T) {
fin := wireLedger(t)
for i := 0; i < 5; i++ {
if _, err := cloud.EnsureStarterCredit(context.Background(), starterWalletOf("acme", "alice")); err != nil {
t.Fatalf("EnsureStarterCredit call %d: %v", i, err)
}
}
if got := gateBalance(t, fin, "acme", "alice"); got != commercecredit.StarterCreditCents {
t.Fatalf("after 5 grants the gate reads %d cents, want %d — the grant stacked",
got, commercecredit.StarterCreditCents)
}
}
// TestStarterCredit_ConcurrentGrantsOnce is the one that matters for money. A
// sequential retry test passes against a read-then-write guard that is still racy;
// only concurrency distinguishes a real idempotency barrier from a checked one.
// Twenty goroutines start together and grant the same wallet.
//
// The barrier under test is finance.Deposit's dedup on Ref, which does its
// EntryByRef check INSIDE the same transaction as the insert, over a store pinned to
// SetMaxOpenConns(1). If that ever loosens — a second connection, a deferred
// transaction, a check moved out of the tx — this test turns the regression into a
// balance that is a multiple of $5.
func TestStarterCredit_ConcurrentGrantsOnce(t *testing.T) {
fin := wireLedger(t)
const racers = 20
var start sync.WaitGroup
var done sync.WaitGroup
start.Add(1)
errs := make([]error, racers)
for i := 0; i < racers; i++ {
done.Add(1)
go func(i int) {
defer done.Done()
start.Wait() // release all goroutines at once
_, errs[i] = cloud.EnsureStarterCredit(context.Background(), starterWalletOf("acme", "alice"))
}(i)
}
start.Done()
done.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("concurrent grant %d failed: %v", i, err)
}
}
if got := gateBalance(t, fin, "acme", "alice"); got != commercecredit.StarterCreditCents {
t.Fatalf("after %d CONCURRENT grants the gate reads %d cents, want %d — the idempotency key is not a barrier under race",
racers, got, commercecredit.StarterCreditCents)
}
}
// TestStarterCredit_PerAccountNotPerProcess proves the key is scoped to the address:
// three different orgs each get their own grant. An over-broad key would fund the
// first and silently skip every one after it.
func TestStarterCredit_PerAccountNotPerProcess(t *testing.T) {
fin := wireLedger(t)
orgs := []string{"acme", "globex", "initech"}
for _, org := range orgs {
if _, err := cloud.EnsureStarterCredit(context.Background(), starterWalletOf(org, "founder")); err != nil {
t.Fatalf("EnsureStarterCredit(%s): %v", org, err)
}
}
for _, org := range orgs {
if got := gateBalance(t, fin, org, "founder"); got != commercecredit.StarterCreditCents {
t.Fatalf("org %s reads %d cents, want %d", org, got, commercecredit.StarterCreditCents)
}
}
}
// TestStarterCredit_FundedAccountIsNotGranted is the guard against a retroactive
// payout to the whole customer base. A first-contact trigger sees every EXISTING
// account on the first request after a deploy; if "unseen" meant "new", every funded
// org in the fleet would take a free $5.
func TestStarterCredit_FundedAccountIsNotGranted(t *testing.T) {
fin := wireLedger(t)
// An existing customer with money on the books.
if _, err := fin.Deposit(context.Background(), types.DepositInput{
Org: "acme", Subject: "acme", Amount: money.FromCents(2500), Currency: "usd",
Notes: "prior top-up", Ref: "prior-topup-1",
}); err != nil {
t.Fatalf("seed deposit: %v", err)
}
if _, err := cloud.EnsureStarterCredit(context.Background(), starterWalletOf("acme", "alice")); err != nil {
t.Fatalf("EnsureStarterCredit: %v", err)
}
if got := gateBalance(t, fin, "acme", "alice"); got != 2500 {
t.Fatalf("funded account reads %d cents, want its original 2500 — a retroactive starter credit was paid", got)
}
}
// TestStarterCredit_SpentAccountIsNotGranted is the other half of that guard, and the
// reason a zero balance alone is not the eligibility test. An org that spent its
// balance down to exactly zero reads $0 but is not new; only its usage history
// distinguishes it from a fresh signup.
func TestStarterCredit_SpentAccountIsNotGranted(t *testing.T) {
fin := wireLedger(t)
ctx := context.Background()
if _, err := fin.Deposit(ctx, types.DepositInput{
Org: "acme", Subject: "acme", Amount: money.FromCents(1000), Currency: "usd",
Notes: "prior top-up", Ref: "prior-topup-1",
}); err != nil {
t.Fatalf("seed deposit: %v", err)
}
if err := fin.RecordUsage(ctx, types.UsageInput{
Org: "acme", Subject: "acme", Amount: money.FromCents(1000), Currency: "usd", RequestID: "prior-usage-1",
}); err != nil {
t.Fatalf("seed usage: %v", err)
}
if got := gateBalance(t, fin, "acme", "alice"); got != 0 {
t.Fatalf("precondition: spent-out org should read 0, got %d", got)
}
if _, err := cloud.EnsureStarterCredit(ctx, starterWalletOf("acme", "alice")); err != nil {
t.Fatalf("EnsureStarterCredit: %v", err)
}
if got := gateBalance(t, fin, "acme", "alice"); got != 0 {
t.Fatalf("a spent-out account was granted %d cents — zero balance was mistaken for a new account", got)
}
}
// TestStarterCredit_PoolIsNotThePersonWallet is the NEGATIVE CONTROL for every other
// test here, and the reason the shared signup org is excluded from the grant.
//
// In that org the members are strangers, so account.Payer resolves each to their OWN
// wallet, not the org pool. An org-keyed grant there lands somewhere no member's gate
// reads. This test asserts that gap exists — it is what makes the passing address
// tests above meaningful rather than vacuous, and it pins the trap so nobody
// "simplifies" the exclusion away.
func TestStarterCredit_PoolIsNotThePersonWallet(t *testing.T) {
fin := wireLedger(t)
org := account.SignupOrg
// Credit the POOL directly (what an org-keyed grant would do).
if _, err := cloud.EnsureStarterCredit(context.Background(), principal.Wallet{Ledger: org, Account: org}); err != nil {
t.Fatalf("EnsureStarterCredit(pool): %v", err)
}
// A member of that org reads their PERSON wallet — EMPTY. This is the
// "looks fixed and 402s anyway" failure.
if got := gateBalance(t, fin, org, "alice"); got != 0 {
t.Fatalf("a signup-org member reads %d cents from a pool grant; the address model is wrong", got)
}
// Same ledger, different account: the pool DID receive it, so the zero above is an
// ADDRESS mismatch, not a failed write.
pool, err := fin.Balance(context.Background(), org, org, "usd", false)
if err != nil {
t.Fatalf("pool balance: %v", err)
}
if pool.Cents() != commercecredit.StarterCreditCents {
t.Fatalf("pool holds %d cents, want %d — the write itself failed, so this proves nothing about addressing",
pool.Cents(), commercecredit.StarterCreditCents)
}
}
// TestStarterCredit_NoLedgerIsInert proves a split deploy is a no-op, not a crash and
// not a false success: with no co-resident ledger there is nothing to grant into, the
// account stays at $0, and the gate refuses it.
func TestStarterCredit_NoLedgerIsInert(t *testing.T) {
creditledger.Set(nil)
finance.Publish(nil)
got, err := cloud.EnsureStarterCredit(context.Background(), starterWalletOf("acme", "alice"))
if err != nil {
t.Fatalf("no-ledger must be inert, got error: %v", err)
}
if got != 0 {
t.Fatalf("no-ledger reported balance %d, want 0", got)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ package cloud
//
// WHERE IT SITS, and why exactly there (serve.go):
//
// SanitizeIdentity → AuditTrail → ScopeRateLimit → AbuseGate → StarterGrant → BillingGate
// SanitizeIdentity → AuditTrail → ScopeRateLimit → AbuseGate → BillingGate
//
// - AFTER SanitizeIdentity, so the org it scopes to is the validated one and
// the credential class it reads cannot be forged.
+13 -9
View File
@@ -49,15 +49,19 @@ type PlanChecker interface {
// 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.
// DEFAULT OFF, AND THAT IS A SEQUENCING DECISION, NOT TIMIDITY. A brand-new signup's
// wallet is $0 and there is NO automatic path that funds it: credit is an admin
// decision, granted deliberately through the admin surface, and the automatic starter
// grant that used to run as middleware here has been deleted outright rather than
// left switched off (an automatic path that mints money is a liability even when
// disabled, because disabled is one flag away from enabled).
//
// So flipping this gate on 402s every new account from its first request. That is a
// real product decision — an honest paywall — and NOT one this flag should make
// silently as a side effect of the grant's removal. Until the paywall's add-credit
// state is the one a new user actually lands in, enforcing here trades a revenue leak
// for a signup that dead-ends. The gate therefore stays behind the kill switch,
// proven by tests, and is flipped as its own deliberate change.
//
// 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
-273
View File
@@ -1,273 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
package cloud
// middleware_starter.go funds a new account the first time it presents a
// credential, and it is the funding path SpendGate is sequenced behind
// (middleware_spend.go: "no ensureStarterCredit runs anywhere"). It does not
// enable enforcement; flipping SwitchPaywallEnforced stays a separate act.
//
// WHY FIRST CONTACT, AFTER TWO SEAMS THAT LOOKED BETTER AND WERE NOT. The grant
// was first wired into cloud's /v1/iam/onboard first-run branch. It shipped in
// v1.801.241, was present in the binary, and NEVER FIRED — twice over:
//
// - api.hanzo.ai/v1/iam/* is routed to the IAM SERVICE at the edge, so cloud's
// onboard handler is not on that path at all. The provisioning that creates
// most orgs happens in a process that has no ledger.
// - on console.hanzo.ai, where cloud DOES serve it, `additional := cr.owner != ""`
// reads the EFFECTIVE org. A fresh IAM-v2 signup lands in the shared signup org,
// so cr.owner is non-empty, every signup takes the "additional" branch, and the
// first-run branch is unreachable.
//
// Both failures share one shape: the trigger depended on WHICH HOST served the
// request and WHICH COMPONENT created the org, and both of those vary. This seam
// depends on neither. It asks the only question with a stable answer — "does the
// wallet this credential spends from exist yet?" — and it asks it in the binary
// that owns the ledger. A grant here cannot be routed around.
//
// COST. This is a hot path: it runs on every request, authenticated or not. The
// steady state is a map load and nothing else — see starterSeen. Only the FIRST
// request per wallet per process touches the ledger.
import (
"context"
"strings"
"sync"
"time"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/plane"
"github.com/hanzoai/commerce/billing/credit"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/zap-proto/zip"
)
// starterSeen is the fast path, and the reason this is mountable app-wide: a wallet
// resolved once in this process is never looked at again. The key is the wallet
// SUBJECT, which is globally unique (an org account is the bare slug, a person's is
// "<org>/<name>"), so no ledger name is needed to disambiguate.
//
// It is a CACHE, not the idempotency barrier. Losing it on restart costs one extra
// ledger check per active wallet and can never double-grant: the barrier is the
// address-keyed Ref inside finance.Deposit's insert transaction. Nothing here is
// load-bearing for money, which is why an unbounded sync.Map is safe — it holds one
// small string per wallet the process has served, and a wallet only enters it by
// presenting a valid credential.
var starterSeen sync.Map // subject -> struct{}
// StarterGrant returns the middleware serve.go mounts ahead of the billing gates, so
// a brand-new account is funded BEFORE the gate on that same request evaluates it —
// otherwise the very first request of every new signup would 402 the moment
// enforcement is switched on, which is the failure this whole path exists to prevent.
//
// It never blocks, never fails a request, and never rejects: funding is not an
// authorization decision. Every outcome falls through to c.Next(). If the grant
// fails the account simply holds no credit, and the gate — which reads the ledger,
// not a grant flag — refuses it. There is no path where a failed grant is mistaken
// for funding.
func StarterGrant() zip.Handler {
return func(c *zip.Ctx) error {
if w, ok := starterWallet(c); ok {
if _, seen := starterSeen.Load(w.Account); !seen {
// Mark BEFORE attempting: a wallet is looked at once per process
// whatever happens, so a persistently failing grant costs one attempt
// rather than one per request. A restart retries it.
starterSeen.Store(w.Account, struct{}{})
_, _ = EnsureStarterCredit(c.Context(), w)
}
}
return c.Next()
}
}
// starterWallet resolves the wallet a request spends from, or ok=false when this
// request must not be funded.
//
// The address is principal.WalletOf — the SAME resolution the spend gate performs
// (middleware_billing.go identityFromCtx) and the same one account.Payer gives the ai
// balance reader. It is not re-derived here, because a second derivation is a second
// rule, and two rules disagreeing about who pays is the bug this package's wallet.go
// documents twice.
//
// THE SHARED SIGNUP ORG IS EXCLUDED, deliberately. Its members are strangers to each
// other (hanzoai/account: "a shared org is not a shared wallet"), so Payer resolves
// each to a PERSON account inside Hanzo's own staff org. Someone parked there has not
// created an account yet — they have a login. Funding those wallets would put consumer
// starter credit inside the platform's own books and pay out twice for one human, who
// gets their grant when they land in a real org.
//
// ONLY THE CALLER'S HOME ORG IS FUNDED, and this is the abuse gate. The payer of
// record is the SELECTED org (principal.BillingOrg reads X-Org-Id; IAM's signed `orgs`
// claim lets a person act in any org they belong to). So one person can present as
// many wallets as they have memberships, and funding "whichever wallet is paying"
// would mint $5 per org to anyone who can create them — which is unlimited, and whose
// personal slugs auto-suffix. The grant therefore follows the PERSON: it fires only
// where the selected org is the caller's own.
//
// MEMBERSHIP ALONE WOULD NOT BE ENOUGH, which is why this reads the home org and not
// the `orgs` set at large. A founder is a member of every org they create, so
// "selected ∈ orgs" admits all of them and the mint is back. What distinguishes the
// one org that is theirs is POSITION: IAM builds the membership set home-first from
// the authoritative user row (store.MemberOrgRefs seeds it with user.Owner, then
// appends explicit memberships), so orgs[0] is the caller's own org and everything
// they merely joined or created follows it.
//
// principal.Owner IS that value. It reads X-User-Owner, which SanitizeIdentity now
// mints from idClaims.homeOrg() — orgs[0].org — rather than from the `owner` claim
// (d14219415: "the home org is the USER's, never the minting app's"). That fix is
// upstream of this gate and is what makes it reachable at all: `owner` carried the
// APPLICATION's org, so it never equalled the ledger for an onboarded account and this
// comparison could not match. Reading the same accessor SanitizeIdentity reads keeps
// this one authority rather than a second opinion.
//
// It costs nothing legitimate. IAM's first-run provision moves the founder into the
// org it creates, so a real new account's home IS the new org and it is funded. Orgs
// created as ADDITIONAL never move the caller (clients/account: "create it WITHOUT
// moving them"), so they can never occupy position 0 and are never funded — the
// intended rule, enforced by the address rather than by a flag on a handler that only
// one of several routes reaches.
//
// Comparison is BYTE-EXACT, and safely so: isMember (auth_identity.go) admits a
// selected org only on `o.Org == org`, so X-Org-Id can only ever be a byte-identical
// entry of the signed set, and X-User-Owner is entry zero of that same set. A case
// difference here would mean the two values did not come from the same claim — a
// reason to refuse a grant, not to normalise until they match. An empty home refuses:
// a pre-v1.33.0 or machine token carries no membership set, and the field it would
// otherwise fall back to is the one that was just removed for being wrong.
func starterWallet(c *zip.Ctx) (principal.Wallet, bool) {
w, ok := principal.WalletOf(c)
if !ok || w.Ledger == "" || w.Account == "" {
return principal.Wallet{}, false
}
if strings.EqualFold(w.Ledger, account.SignupOrg) {
return principal.Wallet{}, false
}
if home := principal.Owner(c); home == "" || home != w.Ledger {
return principal.Wallet{}, false // no signed home, or acting away from it
}
return w, true
}
// EnsureStarterCredit grants the one-time starter credit to wallet and reports the
// balance it left behind, or (0,nil) when the wallet was not eligible.
//
// THE ADDRESS IS THE CALLER'S, NOT A GUESS. It credits (Ledger, Account) exactly as
// principal.WalletOf resolved it, passing Account through as CreditInput.Subject. For
// a real org Payer yields the org pool and Subject is the bare slug; the day IAM mints
// per-member `billing_account` claims it yields a person account and the same field
// carries it. Either way the credit lands where the gate looks, because both come from
// one call to account.Payer.
//
// ELIGIBILITY IS "NEW", NOT "UNSEEN". A first-contact trigger sees every EXISTING
// account too — on the first request after a deploy, every org in the fleet is
// unseen. Granting on unseen alone would hand a retroactive $5 to the entire customer
// base. So a wallet qualifies only if it has no money and no history: a zero balance
// AND zero lifetime usage. A funded org is skipped, an org that has ever spent is
// skipped, and a genuinely new account passes. (Known edge, stated: an old account
// that was refunded to exactly zero and never metered reads as new and is granted
// once. It cannot recur — the Ref below is permanent.)
//
// AMOUNT IS SERVER-AUTHORITATIVE. credit.StarterCreditCents, the shared constant, read
// from the server's own dependency. This function takes no amount and no request body,
// so there is no client field that could reach it.
func EnsureStarterCredit(ctx context.Context, w principal.Wallet) (int64, error) {
led := creditledger.Get()
fin := finance.Current()
if led == nil || fin == nil {
// The ledger is not in THIS process, which is every process but the one that
// mounts commerce. Returning silently here is what left a new account unfunded
// once apps became their own binaries: this middleware runs on every chain,
// found no ledger, and granted nothing — so an org that should have opened
// with the welcome credit opened broke, and the paywall then refused it
// correctly for a reason nobody chose.
//
// Asking twice is safe. The grant's idempotency key is the ACCOUNT and
// nothing else, and finance dedups on it inside the same transaction as the
// insert, so two processes racing the same new wallet still grant once.
return grantStarterPeer(ctx, w)
}
bal, err := fin.Balance(ctx, w.Ledger, w.Account, "usd", false)
if err != nil {
return 0, err
}
if bal.Cents() != 0 {
return 0, nil // funded — not a new account
}
// Lifetime usage. finance sums this per ORG; for a real org the wallet IS the org
// pool, so the two coincide. It is the leg that distinguishes "never had money"
// from "spent it all", which a zero balance alone cannot.
used, err := fin.SumUsageSince(ctx, w.Ledger, false, 0)
if err != nil {
return 0, err
}
if used != 0 {
return 0, nil // has spent before — not a new account
}
_, balanceCents, err := led.Credit(ctx, creditledger.CreditInput{
Org: w.Ledger,
Subject: w.Account,
Currency: "usd",
Reason: "welcome",
Tag: credit.StarterCreditTag,
IdempotencyKey: starterRef(w.Account),
AmountCents: credit.StarterCreditCents,
})
if err != nil {
return 0, err
}
return balanceCents, nil
}
// starterRef is the grant's idempotency key: the ADDRESS it credits, and nothing else.
// Keying on the address is what makes this once per ACCOUNT rather than once per
// session, per login, or per process — a retry, a re-login, a restart that empties
// starterSeen, and two concurrent requests all derive the same key, and finance dedups
// on it inside the same transaction as the insert. It deliberately carries no
// timestamp, nonce, request id or user id: any of those would make a replay a SECOND
// grant, which is the whole failure this key exists to prevent.
func starterRef(subject string) string {
return "starter:" + strings.ToLower(strings.TrimSpace(subject))
}
// grantStarterPeer asks the process that owns the ledger to fund a new wallet.
//
// A failure is not fatal and not retried here: starterSeen has already marked this
// wallet, so a wallet costs one attempt per process rather than one per request, and
// a restart retries it. Funding is not an authorization decision — the gate that
// follows decides what an unfunded account may do, and it fails closed on its own.
func grantStarterPeer(ctx context.Context, w principal.Wallet) (int64, error) {
ctx, cancel := context.WithTimeout(For(ctx, w.Ledger), starterPeerTimeout)
defer cancel()
out, err := Ask[plane.StarterIn, plane.Granted](ctx, "commerce", plane.FinanceStarter,
&plane.StarterIn{Subject: w.Account})
if err != nil {
// No ledger here and no peer serving one: this deployment has no money plane
// at all, which is a legitimate shape and not a fault. Inert, exactly as it
// was before there was a peer to ask — erroring would put a line in the log
// on every first request of every wallet in a deployment that does not bill.
return 0, nil
}
if out == nil {
return 0, nil // nothing granted, which is the "already funded" answer
}
// A peer that ANSWERED and could not be read is different: something is serving
// the op and disagreeing about its shape, which is worth surfacing.
return out.Amount.Minor()
}
// starterPeerTimeout bounds the grant. It runs inside a request's middleware chain,
// so a slow ledger must not hold the request open indefinitely — the caller proceeds
// unfunded and the gate refuses, which is the same outcome as a grant that failed.
const starterPeerTimeout = 10 * time.Second
// GrantStarter funds a wallet from the process that HOLDS the ledger. It is the
// body EnsureStarterCredit runs locally, exported so the commerce app can publish
// it on the internal plane without duplicating the rule — one grant, one place that
// decides whether an account qualifies.
func GrantStarter(ctx context.Context, org, subject string) (int64, error) {
return EnsureStarterCredit(ctx, principal.Wallet{Ledger: org, Account: subject})
}
-366
View File
@@ -1,366 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
package cloud
// middleware_starter_test.go covers what apps/starter_test.go cannot reach from
// there: the middleware's own behaviour on a request. The money proofs — that a grant
// lands at the address the gate reads, and lands once — run in package apps against
// the REAL ledger adapter, because only that package can import it.
//
// What is proven here is the part that makes the seam safe to mount app-wide:
// - it costs ONE ledger look per wallet per process and NOTHING thereafter,
// - it funds nobody it should not (anonymous callers, the shared signup org),
// - it never turns a request into an error.
//
// The ledger is a counting fake on purpose. These tests assert HOW OFTEN the money
// layer is touched and BY WHOM; a real ledger would make the count harder to read and
// would re-prove what package apps already proves.
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/apps/finance"
"github.com/hanzoai/cloud/apps/money"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/zap-proto/zip"
)
// countingFinance is a finance.Client that records how often the money layer is read
// and what address each read named. Balance/usage default to zero, i.e. "a new
// account", so the grant path is the one under test unless a test says otherwise.
type countingFinance struct {
balanceCalls atomic.Int64
usageCalls atomic.Int64
balanceCents int64
usageCents int64
mu sync.Mutex
addrs []string // (ledger, subject) pairs the middleware asked about
}
func (f *countingFinance) Balance(_ context.Context, org, subject, _ string, _ bool) (money.Amount, error) {
f.balanceCalls.Add(1)
f.mu.Lock()
f.addrs = append(f.addrs, org+"|"+subject)
f.mu.Unlock()
return money.FromCents(f.balanceCents), nil
}
func (f *countingFinance) SumUsageSince(_ context.Context, _ string, _ bool, _ int64) (int64, error) {
f.usageCalls.Add(1)
return f.usageCents, nil
}
func (f *countingFinance) Deposit(_ context.Context, _ types.DepositInput) (string, error) {
return "dep_fake", nil
}
func (f *countingFinance) RecordUsage(_ context.Context, _ types.UsageInput) error { return nil }
// countingLedger records every credit the middleware issues.
type countingLedger struct {
credits atomic.Int64
mu sync.Mutex
inputs []creditledger.CreditInput
}
func (l *countingLedger) Credit(_ context.Context, in creditledger.CreditInput) (string, int64, error) {
l.credits.Add(1)
l.mu.Lock()
l.inputs = append(l.inputs, in)
l.mu.Unlock()
return "tx_fake", in.AmountCents, nil
}
func (l *countingLedger) Balance(_ context.Context, _, _ string) (int64, error) { return 0, nil }
// wireFakes installs the counting pair as the process-wide money layer and clears the
// hot-path cache, so each test starts from a cold process.
func wireFakes(t *testing.T) (*countingFinance, *countingLedger) {
t.Helper()
fin, led := &countingFinance{}, &countingLedger{}
finance.Publish(fin)
creditledger.Set(led)
starterSeen = sync.Map{}
t.Cleanup(func() {
finance.Publish(nil)
creditledger.Set(nil)
starterSeen = sync.Map{}
})
return fin, led
}
// starterApp mounts StarterGrant in front of a trivial handler.
func starterApp() *zip.App {
app := zip.New(zip.Config{})
app.Use(StarterGrant())
app.Get("/probe", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
return app
}
// hit drives ONE request as the given principal and returns its status (0 on
// transport error). owner=="" sends no principal headers at all — an anonymous
// caller. These headers are what SanitizeIdentity mints from a verified token;
// forging them at the edge is that middleware's concern and has its own tests, so
// here they simply stand in for an already-validated principal.
//
// starterHit takes no *testing.T and never calls Fatalf, so the concurrency test can call it
// from goroutines (t.Fatalf off the test goroutine is undefined behaviour).
// selected is the org the caller is ACTING IN (X-Org-Id — principal.BillingOrg, the
// payer of record); home is their own org (X-User-Owner, the validated `owner` claim).
// They differ only for an org-switched member, which is exactly the case the grant
// must refuse.
func starterHit(app *zip.App, home, selected, userID string) int {
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
if home != "" {
req.Header.Set("X-User-Id", userID)
req.Header.Set("X-User-Owner", home)
}
if selected != "" {
req.Header.Set("X-Org-Id", selected)
}
resp, err := app.Fiber().Test(req)
if err != nil {
return 0
}
return resp.StatusCode
}
// starterProbe drives n requests from a caller AT HOME (selected == home), the normal
// case, and returns the last status.
func starterProbe(t *testing.T, n int, owner, userID string) int {
t.Helper()
app := starterApp()
status := 0
for i := 0; i < n; i++ {
status = starterHit(app, owner, owner, userID)
}
return status
}
// TestStarterGrant_HotPathTouchesTheLedgerOnce is the cost proof. The middleware is
// mounted app-wide, so a signed-in user hits it on EVERY request; if it read the
// ledger each time it would put a SQLite transaction in front of all authenticated
// traffic. Fifty requests from one principal must produce exactly one look.
func TestStarterGrant_HotPathTouchesTheLedgerOnce(t *testing.T) {
fin, led := wireFakes(t)
if got := starterProbe(t, 50, "acme", "alice"); got != http.StatusOK {
t.Fatalf("probe status %d, want 200", got)
}
if n := fin.balanceCalls.Load(); n != 1 {
t.Fatalf("balance reads = %d over 50 requests, want exactly 1 — the hot path is hitting the ledger", n)
}
if n := led.credits.Load(); n != 1 {
t.Fatalf("credits = %d over 50 requests, want exactly 1", n)
}
}
// TestStarterGrant_CreditsTheGateAddress proves the middleware hands the ledger the
// wallet principal.WalletOf resolved — the same address the gate reads — rather than
// an org name it made up. For a real org that is the pool: Subject == the bare slug.
func TestStarterGrant_CreditsTheGateAddress(t *testing.T) {
_, led := wireFakes(t)
starterProbe(t, 1, "acme", "alice")
if led.credits.Load() != 1 {
t.Fatalf("expected exactly one credit, got %d", led.credits.Load())
}
in := led.inputs[0]
want := account.Payer(account.Credential{Owner: "acme", Name: "alice"}).Subject()
if in.Org != "acme" || in.Subject != want {
t.Fatalf("credited (org=%q subject=%q), want (acme, %q) — the address is not account.Payer's", in.Org, in.Subject, want)
}
if in.IdempotencyKey != "starter:"+want {
t.Fatalf("idempotency key = %q, want %q (the ADDRESS, nothing else)", in.IdempotencyKey, "starter:"+want)
}
if in.AmountCents != 500 {
t.Fatalf("amount = %d, want 500 (server-authoritative)", in.AmountCents)
}
}
// TestStarterGrant_AnonymousIsNotFunded: no validated principal, no wallet, no money.
// An anonymous caller must never cause a ledger write — otherwise an unauthenticated
// request could mint credit against a forged org.
func TestStarterGrant_AnonymousIsNotFunded(t *testing.T) {
fin, led := wireFakes(t)
if got := starterProbe(t, 5, "", ""); got != http.StatusOK {
t.Fatalf("anonymous probe status %d, want 200 (funding must never reject)", got)
}
if fin.balanceCalls.Load() != 0 || led.credits.Load() != 0 {
t.Fatalf("anonymous caller touched money: balance=%d credits=%d, want 0/0",
fin.balanceCalls.Load(), led.credits.Load())
}
}
// TestStarterGrant_SignupOrgIsNotFunded pins the exclusion. Members of the shared
// signup org are strangers who each pay from their own wallet inside Hanzo's staff
// books; they have a login, not an account, and they get their grant when they land
// in a real org. Funding them here would pay twice for one human and put consumer
// credit in the platform's own ledger.
func TestStarterGrant_SignupOrgIsNotFunded(t *testing.T) {
fin, led := wireFakes(t)
if got := starterProbe(t, 3, account.SignupOrg, "alice"); got != http.StatusOK {
t.Fatalf("signup-org probe status %d, want 200", got)
}
if fin.balanceCalls.Load() != 0 || led.credits.Load() != 0 {
t.Fatalf("signup-org member was funded: balance=%d credits=%d, want 0/0",
fin.balanceCalls.Load(), led.credits.Load())
}
}
// TestStarterGrant_DistinctAccountsEachGetOne proves the cache keys on the wallet and
// not on something process-wide: three principals in three orgs each get funded once.
func TestStarterGrant_DistinctAccountsEachGetOne(t *testing.T) {
_, led := wireFakes(t)
for _, org := range []string{"acme", "globex", "initech"} {
starterProbe(t, 4, org, "founder")
}
if n := led.credits.Load(); n != 3 {
t.Fatalf("credits = %d, want 3 (one per account, regardless of request count)", n)
}
}
// TestStarterGrant_SwitchedOrgIsNotFunded is the ABUSE GATE, and the reason the grant
// keys on the caller's home org rather than on whichever wallet is currently paying.
//
// The payer of record is the SELECTED org, and IAM's signed `orgs` claim lets a person
// act in any org they belong to. Creating orgs is unlimited and personal slugs
// auto-suffix, so funding "the wallet that is paying" would mint $5 per org to anyone
// who can click New Organization. A caller acting away from home gets nothing.
func TestStarterGrant_SwitchedOrgIsNotFunded(t *testing.T) {
fin, led := wireFakes(t)
app := starterApp()
// alice's home is acme; she has switched into globex, an org she also belongs to.
for i := 0; i < 5; i++ {
if got := starterHit(app, "acme", "globex", "alice"); got != http.StatusOK {
t.Fatalf("switched-org probe status %d, want 200", got)
}
}
if fin.balanceCalls.Load() != 0 || led.credits.Load() != 0 {
t.Fatalf("a switched-into org was funded: balance=%d credits=%d, want 0/0 — $5 can be minted per org created",
fin.balanceCalls.Load(), led.credits.Load())
}
// Same person, back home: funded exactly once.
starterHit(app, "acme", "acme", "alice")
if n := led.credits.Load(); n != 1 {
t.Fatalf("credits at home = %d, want 1 — the home-org guard is refusing a legitimate grant", n)
}
}
// TestStarterGrant_NoSignedHomeIsNotFunded: a token carrying no membership set — a
// pre-v1.33.0 JWT, an sk- key, a client_credentials machine — mints no
// X-User-Owner, because SanitizeIdentity derives it from orgs[0] and fails closed when
// there is none. The grant must refuse rather than fall back: the field it would fall
// back to (the `owner` claim) is the APPLICATION's org, which is exactly what
// d14219415 removed for letting a caller select the paying tenant.
func TestStarterGrant_NoSignedHomeIsNotFunded(t *testing.T) {
fin, led := wireFakes(t)
app := starterApp()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set("X-User-Id", "alice")
req.Header.Set("X-Org-Id", "acme")
// X-User-Owner deliberately ABSENT — no signed membership set on the token.
if _, err := app.Fiber().Test(req); err != nil {
t.Fatalf("probe: %v", err)
}
if fin.balanceCalls.Load() != 0 || led.credits.Load() != 0 {
t.Fatalf("a token with no signed home was funded: balance=%d credits=%d, want 0/0",
fin.balanceCalls.Load(), led.credits.Load())
}
}
// TestStarterGrant_HomeComparisonIsByteExact pins the comparison. Both sides come from
// the SAME signed set — X-Org-Id can only be a byte-identical member entry (isMember
// compares with ==), and X-User-Owner is entry zero of it — so a case difference means
// the two values did not come from the same claim, and a grant on a mismatched pair
// would be funding an address nobody proved the caller owns.
func TestStarterGrant_HomeComparisonIsByteExact(t *testing.T) {
fin, led := wireFakes(t)
app := starterApp()
if got := starterHit(app, "Acme", "acme", "alice"); got != http.StatusOK {
t.Fatalf("probe status %d, want 200", got)
}
if fin.balanceCalls.Load() != 0 || led.credits.Load() != 0 {
t.Fatalf("case-mismatched home/selected was funded: balance=%d credits=%d, want 0/0",
fin.balanceCalls.Load(), led.credits.Load())
}
}
// TestStarterGrant_FundedAccountIsNotGranted is the retroactive-payout guard at the
// middleware level: on the first request after a deploy every existing org is
// "unseen", and a funded one must still be skipped.
func TestStarterGrant_FundedAccountIsNotGranted(t *testing.T) {
fin, led := wireFakes(t)
fin.balanceCents = 2500 // an existing customer with money
starterProbe(t, 3, "acme", "alice")
if n := led.credits.Load(); n != 0 {
t.Fatalf("credits = %d for a funded account, want 0 — a retroactive starter credit was paid", n)
}
}
// TestStarterGrant_SpentAccountIsNotGranted: zero balance is not enough to call an
// account new. An org that has metered usage has a history, so it is skipped even at
// exactly $0.
func TestStarterGrant_SpentAccountIsNotGranted(t *testing.T) {
fin, led := wireFakes(t)
fin.balanceCents = 0
fin.usageCents = 1000 // has spent before
starterProbe(t, 3, "acme", "alice")
if n := led.credits.Load(); n != 0 {
t.Fatalf("credits = %d for a spent-out account, want 0 — zero balance was mistaken for a new account", n)
}
}
// TestStarterGrant_ConcurrentRequestsGrantOnce covers the cold-start burst: many
// requests for the same brand-new account arriving together, before anything is
// cached. The ledger's address-keyed Ref is the real barrier (proven in package apps
// against the real store); this asserts the middleware does not multiply the work.
func TestStarterGrant_ConcurrentRequestsGrantOnce(t *testing.T) {
_, led := wireFakes(t)
app := starterApp()
const racers = 20
var start, done sync.WaitGroup
start.Add(1)
for i := 0; i < racers; i++ {
done.Add(1)
go func() {
defer done.Done()
start.Wait()
starterHit(app, "acme", "acme", "alice")
}()
}
start.Done()
done.Wait()
if n := led.credits.Load(); n != 1 {
t.Fatalf("credits = %d from %d concurrent cold-start requests, want 1", n, racers)
}
}
// TestStarterGrant_LedgerFailureDoesNotFailTheRequest: funding is not authorization.
// With no money layer at all the middleware must be a pass-through — the account
// simply holds no credit, and the GATE (which reads the ledger, not a grant flag) is
// what refuses it.
func TestStarterGrant_LedgerFailureDoesNotFailTheRequest(t *testing.T) {
finance.Publish(nil)
creditledger.Set(nil)
starterSeen = sync.Map{}
t.Cleanup(func() { starterSeen = sync.Map{} })
if got := starterProbe(t, 3, "acme", "alice"); got != http.StatusOK {
t.Fatalf("status %d with no ledger, want 200 — a funding failure must never reject a request", got)
}
}
-14
View File
@@ -64,7 +64,6 @@ const (
FinanceAuthorize = "finance_authorize" // the prepaid gate
FinanceBalance = "finance_balance"
FinanceRecord = "finance_record" // the meter
FinanceStarter = "finance_starter"
FinanceTxns = "finance_txns"
FinanceUsage = "finance_usage"
@@ -258,19 +257,6 @@ type Balance struct {
Amount Money `json:"amount"`
}
// ---- finance.starter — the welcome grant ----------------------------------
// StarterIn issues the opening credit for an org, once.
type StarterIn struct {
Subject string `json:"subject,omitempty"`
}
// Granted reports what the grant issued. A zero amount with no error is the
// legitimate "already granted" answer, not a failure.
type Granted struct {
Amount Money `json:"amount"`
}
// ---- finance.usage / finance.txns — the statement -------------------------
// UsageRow is one recorded debit: what was metered, how much, and when.
+1 -1
View File
@@ -82,7 +82,7 @@ func TestObsErrorInCrossesThePlane(t *testing.T) {
// added to any of these is the same 24h outage.
func TestNoPlaneTypeCarriesAnUnencodableKind(t *testing.T) {
types := []any{
plane.AuthorizeIn{}, plane.RecordIn{}, plane.BalanceIn{}, plane.StarterIn{},
plane.AuthorizeIn{}, plane.RecordIn{}, plane.BalanceIn{},
plane.SecretIn{}, plane.FilesIn{}, plane.Visibility{}, plane.ReserveIn{},
plane.ObsErrorIn{}, plane.ObsErrorOut{}, plane.Header{},
// ScopeRules is walked, not ScopeRule: the walk descends a slice of
+1 -1
View File
@@ -130,7 +130,7 @@ func TestNoTenantIsRefused(t *testing.T) {
// every guard above becomes advisory.
func TestNoPlaneInputCanNameAnOrg(t *testing.T) {
inputs := []any{
plane.AuthorizeIn{}, plane.RecordIn{}, plane.BalanceIn{}, plane.StarterIn{},
plane.AuthorizeIn{}, plane.RecordIn{}, plane.BalanceIn{},
plane.SecretIn{}, plane.FilesIn{}, plane.Visibility{}, plane.ReserveIn{},
}
for _, in := range inputs {
-11
View File
@@ -397,17 +397,6 @@ func Listen(plugins []Plugin, enable []string) error {
// arms that org at PUT /v1/gateway/config.
app.Use(AbuseGate(deps, deps.Traffic))
// Starter credit — the funding path the two gates below are sequenced behind.
// It needs the gateway-asserted principal to resolve a
// wallet; an unvalidated caller is skipped) and BEFORE both gates, so a brand-new
// account is funded before anything on this same request asks whether it can pay.
// Mounted here rather than at the org-creating handler because that handler is not
// on every path: /v1/iam/* is routed to the IAM service at the edge, and cloud's
// own onboard treats a signup-org caller as already-having-an-org. This position
// depends on neither. Hot-path cost in the steady state is one map load
// (middleware_starter.go); only a wallet's first request in this process reads the
// ledger. It never rejects — funding is not an authorization decision.
app.Use(StarterGrant())
// Billing gate. Sits at the (future) Auth position — after identity is
// established by Recover/RequestID/Logger and before any subsystem mounts —