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
79 changes: 62 additions & 17 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"io"
"math"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -125,34 +126,69 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// the master key is ever used. Only a successful (2xx) call keeps the debit;
// a failed call is refunded below, so users are billed for value, not errors.
var ps ProvisionStore
var creditCost int
var creditCost int // fixed mode: the amount pre-debited (refunded on failure)
var billable bool // response mode: this method-path costs money (CostCredits > 0)
if app.creditEnabled() {
var ok bool
if ps, ok = b.provStore(); !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)
if _, err := ps.Provision(appID, string(caller), ip, app.creditSeed, 0, 0, b.now()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "provision: " + err.Error()})
// Seed the caller on first sight, enforcing the per-IP identity cap: once a
// caller depletes their budget (402 below) they can't farm a fresh $5 grant by
// minting a new pilot identity from the same IP — the (N+1)th distinct caller
// on that IP is refused here with 429.
if _, err := ps.Provision(appID, string(caller), ip, app.creditSeed, app.creditMaxPerIP, app.creditMintCooldown, b.now()); 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": "provision: " + err.Error()})
}
return
}
creditCost = app.costForCall(r.Method, mpath)
admittedCredit, remaining, derr := ps.Debit(appID, string(caller), creditCost)
if derr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "debit: " + derr.Error()})
return
}
if !admittedCredit {
w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(remaining))
writeJSON(w, http.StatusPaymentRequired, map[string]any{
"error": "insufficient credit — per-user budget exhausted", "credits_remaining": remaining, "credits_required": creditCost,
})
return
if app.creditRespCost {
// RESPONSE-COST mode: no up-front debit — the true cost isn't known until
// the partner replies. A billable path (CostCredits > 0) is only refused
// when the caller is already depleted; free/unlisted paths always pass.
// The actual cost is settled after a 2xx (see below).
billable = app.costForCall(r.Method, mpath) > 0
if billable {
bal, derr := ps.Credit(appID, string(caller))
if derr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "credit: " + derr.Error()})
return
}
if bal <= 0 {
w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(bal))
writeJSON(w, http.StatusPaymentRequired, map[string]any{
"error": "insufficient credit — per-user budget exhausted", "credits_remaining": bal,
})
return
}
}
} else {
// FIXED mode: pre-flight reservation of a known per-call cost.
creditCost = app.costForCall(r.Method, mpath)
admittedCredit, remaining, derr := ps.Debit(appID, string(caller), creditCost)
if derr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "debit: " + derr.Error()})
return
}
if !admittedCredit {
w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(remaining))
writeJSON(w, http.StatusPaymentRequired, map[string]any{
"error": "insufficient credit — per-user budget exhausted", "credits_remaining": remaining, "credits_required": creditCost,
})
return
}
}
}
// refundCredit returns this call's debit (used on any non-success) so failures
// never burn budget.
// refundCredit returns this call's fixed-mode debit (used on any non-success) so
// failures never burn budget. Response mode does no up-front debit, so it's a no-op.
refundCredit := func() {
if app.creditEnabled() && creditCost > 0 {
ps.Refund(appID, string(caller), creditCost)
Expand Down Expand Up @@ -197,6 +233,15 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
refundCredit()
} else if c := extractCost(rb, app.CostField); c > 0 {
b.Store.AddCost(appID, string(caller), c)
// Response-cost mode: settle the ACTUAL cost the partner reported against the
// caller's micro-dollar budget (clamped so the balance floors at zero). This is
// what makes metering true to real usage for a meta-API whose per-call price is
// only known from the response and can be "dynamic".
if app.creditEnabled() && app.creditRespCost && billable {
if micros := int(math.Round(c * float64(app.creditCostScale))); micros > 0 {
_, _ = ps.Settle(appID, string(caller), micros)
}
}
}

// Surface the caller's remaining budget on every metered response.
Expand Down
54 changes: 50 additions & 4 deletions internal/broker/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,14 @@ type AppEntry struct {
allowPatterns [][]string // templated allow entries split on "/" ("{x}" matches any one segment)
breaker *Breaker

creditSeed int // Credit.SeedCredits (0 ⇒ no budget)
creditDefault int // Credit.DefaultCost (micro-$ per call for un-listed paths)
creditExact map[string]int // exact method-path → micro-$ cost
creditPatterns []costPattern // templated method-path → micro-$ cost
creditSeed int // Credit.SeedCredits (0 ⇒ no budget)
creditDefault int // Credit.DefaultCost (micro-$ per call for un-listed paths)
creditExact map[string]int // exact method-path → micro-$ cost
creditPatterns []costPattern // templated method-path → micro-$ cost
creditMaxPerIP int // Credit.MaxIdentitiesPerIP (0 ⇒ unlimited): distinct callers seedable per source IP
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)

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 @@ -115,6 +119,38 @@ type CreditSpec struct {
// POST /v1/numbers (buy, $3). Method-specific keys win over any-method keys.
CostCredits map[string]int `json:"cost_credits"`
DefaultCost int `json:"default_cost"` // debit for calls not matched above (default 1)

// MaxIdentitiesPerIP caps how many DISTINCT callers may be seeded a fresh budget
// from one source IP (0 = unlimited). This is the anti-Sybil guard that makes the
// free per-user seed safe: once a caller depletes their budget they get 402, and
// they cannot farm a new $5 grant by minting a fresh pilot identity from the same
// machine — the (N+1)th distinct caller on an IP is refused (429) at first sight.
// Source IP is read only from the broker's trusted proxy header (never client
// X-Forwarded-For). Same enforcement as ProvisionSpec.MaxIdentitiesPerIP, applied
// to the plain-HTTP credit path.
MaxIdentitiesPerIP int `json:"max_identities_per_ip"`
// MintCooldownMs is a per-caller re-touch cooldown in ms (0 = none); mirrors
// ProvisionSpec.MintCooldownMs to slow rapid re-provision churn.
MintCooldownMs int `json:"mint_cooldown_ms"`

// CostSource selects how the per-call debit is computed:
// "" → FIXED (default): debit CostCredits[method-path] up front, refund
// on a non-2xx (the classic pre-flight reservation).
// "response" → RESPONSE-COST: the true cost is only known from the partner
// response (e.g. a meta-API that returns the price of the sub-call
// it dispatched, incl. "dynamic" endpoints priced only after the
// fact). No up-front debit; instead, a call whose CostCredits entry
// is > 0 is treated as BILLABLE and is refused with 402 when the
// caller's balance is already <= 0 (free/unlisted paths always pass),
// and after a 2xx the ACTUAL cost read from the response (AppEntry
// CostField × CostScale, in micro-$) is settled against the budget,
// clamped so the balance floors at zero. This keeps metering true to
// real usage when per-endpoint prices can't be tabulated in advance.
CostSource string `json:"cost_source"`
// CostScale converts one unit of the response CostField into micro-dollars
// (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"`
}

// costPattern is a templated cost key split into segments (like allowPatterns),
Expand Down Expand Up @@ -294,6 +330,16 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) {
if a.creditDefault < 0 {
a.creditDefault = 0
}
a.creditMaxPerIP = a.Credit.MaxIdentitiesPerIP
if a.creditMaxPerIP < 0 {
a.creditMaxPerIP = 0
}
a.creditMintCooldown = time.Duration(a.Credit.MintCooldownMs) * time.Millisecond
a.creditRespCost = a.Credit.CostSource == "response"
a.creditCostScale = a.Credit.CostScale
if a.creditCostScale <= 0 {
a.creditCostScale = 1
}
a.creditExact = map[string]int{}
a.creditPatterns = nil
for k, c := range a.Credit.CostCredits {
Expand Down
24 changes: 24 additions & 0 deletions internal/broker/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ type ProvisionStore interface {
// what was debited in practice (callers only refund what they reserved).
Refund(app, caller string, n int)

// Settle debits n credits AFTER the fact, clamped to the available balance so
// it never rejects and never drives the balance below zero. Unlike Debit (a
// pre-flight reservation that fails when funds are short), Settle is used by the
// response-cost credit path where the true cost is only known from the partner
// response: the call already ran, so the debit must always apply. It returns the
// remaining balance. n <= 0 and an unprovisioned caller are no-ops.
Settle(app, caller string, n int) (remaining int, err error)

// Get returns the caller's provision record (for reading the current rotation
// counter + credit). exists is false for an unprovisioned caller.
Get(app, caller string) (rec ProvisionRecord, exists bool, err error)
Expand Down Expand Up @@ -169,6 +177,22 @@ func (s *MemStore) Refund(app, caller string, n int) {
}
}

func (s *MemStore) Settle(app, caller string, n int) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec := s.prov[key(app, caller)]
if rec == nil {
return 0, nil
}
if n > 0 {
if n > rec.Credits {
n = rec.Credits // clamp: floor the balance at zero
}
rec.Credits -= n
}
return rec.Credits, nil
}

func (s *MemStore) Get(app, caller string) (ProvisionRecord, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down
30 changes: 30 additions & 0 deletions internal/broker/store_sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,36 @@ func (s *SQLiteStore) Refund(app, caller string, n int) {
_, _ = s.db.Exec(`UPDATE provision SET credits=credits+? WHERE app=? AND caller=?`, n, app, caller)
}

// Settle debits n post-hoc, clamped to the balance (never below zero, never
// rejects). Serialized by the single-conn pool like the other ledger ops.
func (s *SQLiteStore) Settle(app, caller string, n int) (int, error) {
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
var credits int
err = tx.QueryRow(`SELECT credits FROM provision WHERE app=? AND caller=?`, app, caller).Scan(&credits)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
if n > credits {
n = credits // clamp to floor at zero
}
if n > 0 {
if _, err := tx.Exec(`UPDATE provision SET credits=credits-? WHERE app=? AND caller=?`, n, app, caller); err != nil {
return credits, err
}
}
if err := tx.Commit(); err != nil {
return credits, err
}
return credits - n, nil
}

func (s *SQLiteStore) Get(app, caller string) (ProvisionRecord, bool, error) {
var ip string
var firstSeen, lastMint int64
Expand Down
Loading
Loading