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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/MANAGED-KEY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ns>.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 **`<ns>.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 `<ns>.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
`<ns>.balance` for free; document in the app's `app_description` that agents should
call `<ns>.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
Expand Down
11 changes: 11 additions & 0 deletions docs/PUBLISHING-PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **`<ns>.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 `<ns>.balance` (or read the
> `X-Pilot-Credits-Remaining` header) before a spend op.

## Step 1 — Author `pilot.app.yaml`

```bash
Expand Down
13 changes: 12 additions & 1 deletion internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ns>.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
Expand Down Expand Up @@ -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
// <ns>.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
}
Expand Down
76 changes: 76 additions & 0 deletions internal/broker/zz_balance_meta_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
32 changes: 32 additions & 0 deletions internal/scaffold/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ns>.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 }

Expand Down Expand Up @@ -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 <ns>.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\":<micro-$>,\"credits_seed\":<micro-$>,\"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 == "" {
Expand Down
119 changes: 119 additions & 0 deletions internal/scaffold/zz_balance_method_test.go
Original file line number Diff line number Diff line change
@@ -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 `<ns>.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 <ns>.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)
}
}
Loading