From 6b85b2bcc1f3773e2c1987c77daf6c8f7eee5560 Mon Sep 17 00:00:00 2001 From: Alex Godoroja <50743382+Alexgodoroja@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:20:12 -0700 Subject: [PATCH] scaffold+broker: dedicated .balance method for managed budget apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed (credit-metered) apps could meter a per-user $-budget and surface it on the X-Pilot-Credits-Remaining header, but a keyless adapter only returns the response body — so an agent could never actually read its balance, and there was no first-class way to check funds before a spend op. This wires a dedicated balance method, end to end: - Broker answers a canonical /_pilot/balance for any credit app from its own per-caller ledger — no upstream call, no master key, no debit, never a 402, scoped to the caller (the pooled account balance is never disclosed). Reuses the existing serveCreditBalance (which also backs the optional creditBalancePath partner-endpoint shadow); the canonical path is always available alongside it. - Scaffolder auto-injects `.balance` into every `auth: managed` app (no submission field), a GET to that path — flowing through registration, the manifest `exposes` list, and `.help` like any authored method. - scaffold.BalanceMetaPath == broker.pilotBalancePath keeps the two ends in sync. - Tests: broker (canonical path seeds, reflects spend, free, never forwards) and scaffold (injected for managed only, generated + compiles + in the manifest). - Docs: MANAGED-KEY.md and the publishing playbook document the pattern for all broker apps that meter a budget. --- docs/MANAGED-KEY.md | 36 ++++++ docs/PUBLISHING-PLAYBOOK.md | 11 ++ internal/broker/broker.go | 13 ++- internal/broker/zz_balance_meta_test.go | 76 +++++++++++++ internal/scaffold/config.go | 32 ++++++ internal/scaffold/zz_balance_method_test.go | 119 ++++++++++++++++++++ 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 internal/broker/zz_balance_meta_test.go create mode 100644 internal/scaffold/zz_balance_method_test.go diff --git a/docs/MANAGED-KEY.md b/docs/MANAGED-KEY.md index 838e380..a987a7e 100644 --- a/docs/MANAGED-KEY.md +++ b/docs/MANAGED-KEY.md @@ -205,6 +205,42 @@ and, once spent, return **`402 Payment Required`**. Add a `credit` block: with per-user key minting) are **mutually exclusive**. Both need a durable store in prod (`BROKER_DB`) so balances survive a restart. +### Checking the balance: the `.balance` method + +The remaining budget rides on the `X-Pilot-Credits-Remaining` header of every +metered response — but a keyless adapter only surfaces the response **body**, so +that header is invisible to the agent. So every **managed** app also gets a +dedicated, free balance method, wired automatically — no submission field needed: + +- The scaffolder injects **`.balance`** into any app with `auth: managed` + (see `Config.Resolve`). It is a `GET` to the broker's canonical + **`/_pilot/balance`** route (`scaffold.BalanceMetaPath` == + `broker.pilotBalancePath`), and it shows up in the manifest `exposes` list and + in `.help` like any other method. +- The broker answers `/_pilot/balance` for any **credit**-metered app **before** + the allow-list, seeds a first-seen caller so they see their full budget, and + returns the ledger read **without forwarding upstream, touching the master key, + or debiting** — a pure read that can **never** `402`, scoped to THIS caller (the + shared account's pooled balance is never disclosed): + + ```json + { "balance": "$1.80", "credits_remaining": 1800000, "credits_seed": 5000000, + "unit": "micro_usd", "scope": "per-pilot-user" } + ``` + +- `provision` apps keep their own `/_balance` route instead. A managed app may + additionally set `credit.balance_path` to **shadow a partner's own + account-balance endpoint** (so calling it returns the per-user budget instead of + leaking the pooled account) — that is answered by the same handler, alongside the + canonical `/_pilot/balance`. + +**Playbook — any broker app that meters a budget:** rely on this rather than +hand-rolling a balance endpoint. Set the `credit` block, and the adapter exposes +`.balance` for free; document in the app's `app_description` that agents should +call `.balance` (or read `X-Pilot-Credits-Remaining`) to check funds before a +spend op, and that spend ops return `402` with `credits_remaining` / +`credits_required` when the budget is exhausted. + ## Operating the broker ```bash diff --git a/docs/PUBLISHING-PLAYBOOK.md b/docs/PUBLISHING-PLAYBOOK.md index 6c86aae..da0e57a 100644 --- a/docs/PUBLISHING-PLAYBOOK.md +++ b/docs/PUBLISHING-PLAYBOOK.md @@ -54,6 +54,17 @@ Two orthogonal choices. Get these right first; everything else follows. > go-live: register the app + master key with the broker and SIGHUP it (Step 7.5). Until > that is done the managed app installs but every call 5xx's at the broker. +> **Budget + balance (managed apps).** To meter a **dollar budget per user** (not just a +> rate quota), add a `credit` block to the broker registration — each user is seeded a fixed +> amount and spend ops return `402` once it's gone; reads stay free (see +> [`MANAGED-KEY.md`](MANAGED-KEY.md#per-user-spending-budget-credit--402)). You get balance +> checking **for free**: every `managed` app auto-exposes a **`.balance`** method (a +> `GET` to the broker's canonical `/_pilot/balance`) that returns +> `{balance:"$X.XX", credits_remaining, credits_seed, unit:"micro_usd", scope:"per-pilot-user"}` +> without a partner call or a charge — no submission field to add. In the app's +> `app_description`, tell agents to call `.balance` (or read the +> `X-Pilot-Credits-Remaining` header) before a spend op. + ## Step 1 — Author `pilot.app.yaml` ```bash diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 1c8925a..4523360 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -67,6 +67,13 @@ func microUSD(micros int) string { return fmt.Sprintf("$%d.%02d", micros/1_000_000, (micros%1_000_000)/10_000) } +// pilotBalancePath is the canonical per-user credit-balance route: every credit +// app answers it from the broker's own ledger (never forwarded), and the +// generated `.balance` adapter method dials it. MUST match +// scaffold.BalanceMetaPath. Always available on a credit app, in addition to any +// creditBalancePath that shadows a partner's own account-balance endpoint. +const pilotBalancePath = "/_pilot/balance" + // serveCreditBalance answers a credit app's balance path from the per-caller // ledger. It NEVER contacts the partner, so the shared master account's pooled // balance is not exposed — only this caller's own remaining budget. The caller is @@ -148,7 +155,11 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { // OWN ledger — never forwarding to the partner — so the shared account's // pooled balance is never disclosed. Returns only THIS caller's remaining // micro-$ budget (seeding on first sight, so the per-IP cap applies here too). - if app.creditEnabled() && app.creditBalancePath != "" && mpath == app.creditBalancePath { + // Two paths reach it: the canonical /_pilot/balance (what the generated + // .balance method dials — always available on a credit app) and an + // optional creditBalancePath that SHADOWS a partner's account-balance + // endpoint so it can't leak the pooled account. + if app.creditEnabled() && (mpath == pilotBalancePath || (app.creditBalancePath != "" && mpath == app.creditBalancePath)) { b.serveCreditBalance(w, r, app, string(caller)) return } diff --git a/internal/broker/zz_balance_meta_test.go b/internal/broker/zz_balance_meta_test.go new file mode 100644 index 0000000..559c3a3 --- /dev/null +++ b/internal/broker/zz_balance_meta_test.go @@ -0,0 +1,76 @@ +package broker + +import ( + "crypto/ed25519" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// balanceOf issues a signed GET to the canonical credit-balance route and returns +// the decoded credits_remaining plus the raw recorder. +func balanceOf(t *testing.T, b *Broker, priv ed25519.PrivateKey, now time.Time) (int, *httptest.ResponseRecorder) { + t.Helper() + rec := httptest.NewRecorder() + b.ServeHTTP(rec, signedReq(t, priv, "GET", "/io.pilot.test/_pilot/balance", nil, now)) + var out struct { + Balance string `json:"balance"` + CreditsRemaining int `json:"credits_remaining"` + CreditsSeed int `json:"credits_seed"` + Unit string `json:"unit"` + Scope string `json:"scope"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if rec.Code == http.StatusOK { + if out.Unit != "micro_usd" || out.Scope != "per-pilot-user" { + t.Fatalf("balance unit/scope = %q/%q, want micro_usd/per-pilot-user (body %s)", out.Unit, out.Scope, rec.Body.String()) + } + } + return out.CreditsRemaining, rec +} + +// TestBalanceMeta_CanonicalFreeReadReflectsSpend verifies the canonical +// /_pilot/balance route: it seeds a fresh caller, reflects debits, never touches +// the upstream, and never charges — all without any balance_path config. +func TestBalanceMeta_CanonicalFreeReadReflectsSpend(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + b, hits := creditBroker(t, 200, now) // credit app, no balance_path set + _, priv := newKey(t) + + // 1. First-ever call is the balance check itself → caller seeded to 100, + // reported, upstream never hit, header set. + bal, rec := balanceOf(t, b, priv, now) + if rec.Code != http.StatusOK { + t.Fatalf("balance status %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } + if bal != 100 { + t.Fatalf("seeded balance = %d, want 100", bal) + } + if rec.Header().Get("X-Pilot-Credits-Remaining") != "100" { + t.Fatalf("balance header = %q, want 100", rec.Header().Get("X-Pilot-Credits-Remaining")) + } + if *hits != 0 { + t.Fatalf("balance check hit the upstream %d times, want 0", *hits) + } + + // 2. Spend 40 on /echo → remaining 60. + spend := httptest.NewRecorder() + b.ServeHTTP(spend, signedReq(t, priv, "POST", "/io.pilot.test/echo", []byte(`{}`), now)) + if spend.Code != http.StatusOK { + t.Fatalf("/echo status %d, want 200", spend.Code) + } + + // 3. Balance reflects the spend, still free and still no upstream hit. + hitsAfterSpend := *hits + for i := 0; i < 3; i++ { + bal, rec = balanceOf(t, b, priv, now) + if rec.Code != http.StatusOK || bal != 60 { + t.Fatalf("post-spend balance = %d (status %d), want 60", bal, rec.Code) + } + } + if *hits != hitsAfterSpend { + t.Fatal("balance check debited/forwarded — it must be a pure read") + } +} diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 067cf8e..8ec6e6b 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -353,6 +353,13 @@ func (c *Config) LocalStores() []LocalStoreGrant { // whose http.path equals it as the provisioning call (no request body forwarded). const DefaultProvisionPath = "/_provision" +// BalanceMetaPath is the broker's reserved credit-balance route for managed +// (credit-metered) apps. A GET to this path returns the caller's remaining +// per-user budget straight from the broker's credit ledger — no partner API +// call, no debit, never a 402. Managed apps get a `.balance` method wired to +// it automatically (see Resolve). MUST match the broker's pilotBalancePath. +const BalanceMetaPath = "/_pilot/balance" + // ProvisionPath is the reserved provision route the generated adapter recognizes. func (c *Config) ProvisionPath() string { return DefaultProvisionPath } @@ -659,6 +666,31 @@ func (c *Config) Resolve() { x.Asset = "USDC" } } + // Managed (credit-metered) apps get a dedicated, free balance method wired to + // the broker's reserved credit-ledger route. It makes "how much budget do I + // have left?" a first-class call — not just a header on other responses — and + // it never costs anything. Injected before the normalization loop below so it + // picks up the same Kind/Duration/Timeout defaults as an authored method, and + // flows through registration, the manifest `exposes` list, and .help. + if c.Managed() { + balName := c.Namespace + ".balance" + has := false + for i := range c.Methods { + if c.Methods[i].Name == balName { + has = true + break + } + } + if !has { + c.Methods = append(c.Methods, Method{ + Name: balName, + Summary: "Your remaining Pilot budget for this app, read free from the broker's per-user credit ledger — returns {\"balance\":\"$X.XX\",\"credits_remaining\":,\"credits_seed\":,\"unit\":\"micro_usd\",\"scope\":\"per-pilot-user\"}. This is YOUR budget, not the shared account's. No partner API call, no charge, and never a 402. The same figure also rides on the X-Pilot-Credits-Remaining header of every metered response; call this when you just want to check what's left before a spend.", + Kind: "meta", + Duration: "fast", + HTTP: &HTTPRoute{Verb: "GET", Path: BalanceMetaPath}, + }) + } + } for i := range c.Methods { m := &c.Methods[i] if m.Kind == "" { diff --git a/internal/scaffold/zz_balance_method_test.go b/internal/scaffold/zz_balance_method_test.go new file mode 100644 index 0000000..02f8501 --- /dev/null +++ b/internal/scaffold/zz_balance_method_test.go @@ -0,0 +1,119 @@ +package scaffold + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const managedBalanceSpec = ` +id: io.pilot.paidapi +app_version: 0.1.0 +description: "A managed, credit-metered API." +backend: + base_url: https://api.example.com + auth: managed +methods: + - name: paidapi.run + summary: "Do a paid thing." + http: { verb: POST, path: /v1/run } +` + +const byoBalanceSpec = ` +id: io.pilot.freeapi +app_version: 0.1.0 +description: "A bring-your-own-key API (no broker, no budget)." +backend: + base_url: https://api.example.com + auth: byo +methods: + - name: freeapi.run + summary: "Do a thing." + http: { verb: GET, path: /v1/run } +` + +// hasMethod reports whether the resolved config carries a method by name. +func hasMethod(c *Config, name string) *Method { + for i := range c.Methods { + if c.Methods[i].Name == name { + return &c.Methods[i] + } + } + return nil +} + +// TestBalanceMethod_InjectedForManagedOnly pins the auto-injected balance method: +// managed (broker + budget) apps get a free `.balance` wired to the reserved +// credit route; byo apps (no broker) don't. +func TestBalanceMethod_InjectedForManagedOnly(t *testing.T) { + managed := parseSpec(t, managedBalanceSpec) + bal := hasMethod(managed, "paidapi.balance") + if bal == nil { + t.Fatal("managed app missing auto-injected paidapi.balance method") + } + if bal.HTTP == nil || bal.HTTP.Verb != "GET" || bal.HTTP.Path != BalanceMetaPath { + t.Fatalf("balance route = %+v, want GET %s", bal.HTTP, BalanceMetaPath) + } + if bal.Kind != "meta" { + t.Errorf("balance Kind = %q, want meta", bal.Kind) + } + + byo := parseSpec(t, byoBalanceSpec) + if hasMethod(byo, "freeapi.balance") != nil { + t.Error("byo app should NOT get a balance method (no broker/budget)") + } +} + +// TestBalanceMethod_Generated verifies the injected method reaches the generated +// adapter: registered against the reserved path, listed in the manifest exposes, +// discoverable in .help, and the project still compiles. +func TestBalanceMethod_Generated(t *testing.T) { + if testing.Short() { + t.Skip("skipping compile test in -short mode") + } + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not available") + } + cfg := parseSpec(t, managedBalanceSpec) + if errs := cfg.Validate(); len(errs) != 0 { + t.Fatalf("validate: %v", errs) + } + dir := t.TempDir() + if _, err := Generate(cfg, dir); err != nil { + t.Fatalf("generate: %v", err) + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + _ = os.WriteFile(filepath.Join(dir, "go.sum"), sum, 0o644) + } + + main, err := os.ReadFile(filepath.Join(dir, "cmd", cfg.BinaryName, "main.go")) + if err != nil { + t.Fatalf("read main.go: %v", err) + } + for _, want := range []string{ + `d.Register("paidapi.balance"`, // registered + `pathTmpl: "` + BalanceMetaPath + `"`, // dialing the reserved route + } { + if !strings.Contains(string(main), want) { + t.Errorf("generated main.go missing: %s", want) + } + } + + mf, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + if !strings.Contains(string(mf), `"paidapi.balance"`) { + t.Error("manifest exposes list missing paidapi.balance") + } + + cmd := exec.Command(goBin, "build", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated managed project failed to compile: %v\n%s", err, out) + } +}