Skip to content
Merged
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
52 changes: 52 additions & 0 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
Expand Down Expand Up @@ -58,6 +59,48 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
_ = json.NewEncoder(w).Encode(v)
}

// microUSD formats a micro-dollar amount as "$X.XX" (floored at zero).
func microUSD(micros int) string {
if micros < 0 {
micros = 0
}
return fmt.Sprintf("$%d.%02d", micros/1_000_000, (micros%1_000_000)/10_000)
}

// 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
// seeded on first sight (so the per-IP cap applies), mirroring a first call.
func (b *Broker) serveCreditBalance(w http.ResponseWriter, r *http.Request, app *AppEntry, caller string) {
ps, ok := b.provStore()
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store does not support credit metering"})
return
}
ip := clientIP(r.Header.Get, r.RemoteAddr, b.IPTrust)
rec, err := ps.Provision(app.ID, caller, ip, app.creditSeed, app.creditMaxPerIP, app.creditMintCooldown, b.now())
if err != nil {
switch err {
case ErrIPCap:
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "per-IP identity cap reached — too many pilot identities have claimed a budget from this network"})
case ErrCooldown:
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "re-provision cooldown — retry shortly"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "balance: " + err.Error()})
}
return
}
w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(rec.Credits))
writeJSON(w, http.StatusOK, map[string]any{
"balance": microUSD(rec.Credits),
"credits_remaining": rec.Credits,
"credits_seed": app.creditSeed,
"unit": "micro_usd",
"scope": "per-pilot-user",
"note": "Your personal budget on this app. The shared provider account balance is not exposed.",
})
}

// ServeHTTP is the forward path for /<app-id>/<method-path>.
func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
appID, mpath, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
Expand Down Expand Up @@ -101,6 +144,15 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

// 2b. Per-user balance: a credit app can answer a balance path from the broker's
// 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 {
b.serveCreditBalance(w, r, app, string(caller))
return
}

// 3. Is this an allowed method? (no open proxy onto the master key)
if !app.allowed(mpath) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "method not allowed for this app: " + mpath})
Expand Down
13 changes: 13 additions & 0 deletions internal/broker/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type AppEntry struct {
creditMintCooldown time.Duration // Credit.MintCooldownMs: per-caller re-seed touch cooldown (0 ⇒ none)
creditRespCost bool // Credit.CostSource == "response": debit actual cost from the response, not a fixed pre-debit
creditCostScale int // Credit.CostScale: micro-$ per unit of CostField (response mode; default 1)
creditBalancePath string // Credit.BalancePath: broker answers this path from the ledger (never forwarded)

deriveSecret []byte // provisioned: HMAC secret (current version) resolved from Provision.SecretEnv
secretsByVersion map[byte][]byte // provisioned: {version: secret} accepted during a rotation grace window
Expand Down Expand Up @@ -151,6 +152,17 @@ type CreditSpec struct {
// (response mode only). E.g. a CostField reporting whole cents → 10000
// (1¢ = $0.01 = 10000 micro-$); a field reporting dollars → 1000000. Default 1.
CostScale int `json:"cost_scale"`

// BalancePath, when set, is a request path the broker answers ITSELF from the
// per-caller ledger — it is NEVER forwarded to the partner. This exists to close
// a privacy leak: a shared-master-key app whose partner exposes an account-wide
// "balance"/"credits" endpoint would otherwise reveal the WHOLE account's balance
// (every pilot user's spend, pooled) to any single caller. Instead, the broker
// returns only that caller's own remaining micro-$ budget. The partner's account
// balance is never disclosed. The caller is seeded on first sight (same as a
// first call), so the per-IP cap applies here too. Keep the partner's real
// account-balance path OUT of the allow-list so it can't be forwarded.
BalancePath string `json:"balance_path"`
}

// costPattern is a templated cost key split into segments (like allowPatterns),
Expand Down Expand Up @@ -340,6 +352,7 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) {
if a.creditCostScale <= 0 {
a.creditCostScale = 1
}
a.creditBalancePath = a.Credit.BalancePath
a.creditExact = map[string]int{}
a.creditPatterns = nil
for k, c := range a.Credit.CostCredits {
Expand Down
63 changes: 63 additions & 0 deletions internal/broker/zz_credit_respcost_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package broker

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
Expand Down Expand Up @@ -153,6 +154,68 @@ func TestRespCost_FailedCallIsFree(t *testing.T) {
}
}

// TestCreditBalance_PerUserNotAccount: the broker answers the balance path from its
// OWN ledger and never forwards it, so the shared account balance is never exposed —
// the caller sees only their own remaining budget, which reflects debits.
func TestCreditBalance_PerUserNotAccount(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
var upHits int
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upHits++
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"success":true,"priceCents":1,"balance":"$54321.00"}`))
}))
t.Cleanup(up.Close)
reg, err := ParseRegistry([]byte(`[{
"id":"io.pilot.orthogonal","upstream":"`+up.URL+`","key_env":"ORTH_KEY",
"auth_header":"Authorization","auth_scheme":"Bearer","cost_field":"priceCents",
"allow":["/v1/run"],
"credit":{"seed_credits":5000000,"default_cost":0,"cost_source":"response","cost_scale":10000,
"cost_credits":{"POST /v1/run":1},"balance_path":"/v1/credits/balance"}
}]`), func(string) string { return "MASTERKEY" })
if err != nil {
t.Fatalf("ParseRegistry: %v", err)
}
b := New(reg, NewMemStore())
b.Verify = VerifyConfig{Now: fixedClock(now)}
_, priv := newKey(t)

balance := func() map[string]any {
rec := httptest.NewRecorder()
b.ServeHTTP(rec, signedReq(t, priv, "GET", "/io.pilot.orthogonal/v1/credits/balance", nil, now))
if rec.Code != 200 {
t.Fatalf("balance: status %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var m map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatalf("balance decode: %v", err)
}
return m
}

// Fresh caller → seeded $5, and the upstream (which would leak "$54321.00") is
// NOT contacted.
m := balance()
if m["balance"] != "$5.00" || m["scope"] != "per-pilot-user" {
t.Fatalf("balance = %v, want $5.00 / per-pilot-user", m)
}
if upHits != 0 {
t.Fatal("balance path was forwarded upstream — account balance could leak")
}
// Spend $0.01 via a run, then balance reflects the per-user debit.
run := httptest.NewRecorder()
b.ServeHTTP(run, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now))
if run.Code != 200 {
t.Fatalf("run: %d", run.Code)
}
if m := balance(); m["balance"] != "$4.99" {
t.Fatalf("post-spend balance = %v, want $4.99", m["balance"])
}
if upHits != 1 { // only the run hit upstream; neither balance call did
t.Fatalf("upstream hits = %d, want 1 (run only)", upHits)
}
}

// TestCreditPath_PerIPIdentityCap: with max_identities_per_ip=1, a second DISTINCT
// pilot identity from the same source IP cannot claim a fresh budget — this is the
// anti-Sybil guard that stops farming new $5 grants after depletion. Both callers
Expand Down
Loading
Loading