diff --git a/docs/MANAGED-KEY.md b/docs/MANAGED-KEY.md index 23cb52b..838e380 100644 --- a/docs/MANAGED-KEY.md +++ b/docs/MANAGED-KEY.md @@ -174,19 +174,28 @@ and, once spent, return **`402 Payment Required`**. Add a `credit` block: "allow": ["/v1/messages", "/v1/calls", "/v1/calls/{id}", "/v1/numbers"], "credit": { "seed_credits": 5000000, - "default_cost": 1000, - "cost_credits": { "/v1/numbers": 3000000, "/v1/calls": 50000, "/v1/messages": 10000 } + "default_cost": 0, + "cost_credits": { + "POST /v1/numbers": 3000000, + "POST /v1/calls": 50000, + "POST /v1/messages": 10000 + } } }] ``` - **Unit is micro-dollars** (1 = $0.000001), so `5000000` = **$5**. Match the `cost_credits` to the partner's real prices (here: $3 to buy a number, $0.05 a - call, $0.01 a text); any allowed path not listed costs `default_cost`. + call, $0.01 a text); any call not matched costs `default_cost`. +- **`default_cost: 0` makes reads free** — only the priced calls debit, so polling + for status/replies never burns budget. +- **Cost keys can be method-specific** (`"POST /v1/numbers"`) or any-method + (`"/v1/usage"`), and the path may be templated (`"/v1/calls/{id}"`). This matters + when one path is both a free read and a paid write — `GET /v1/numbers` (list, + free) vs `POST /v1/numbers` (buy, $3). A method-specific key wins over any-method. - **How it works:** on a caller's first call the broker seeds `seed_credits`, - then debits the path's cost **before** touching the master key. A call that - would overdraw is refused with `402` (the master key is never used). Costs - support templated paths (`/v1/calls/{id}`), matched like the allow-list. + then debits the call's cost **before** touching the master key. A call that + would overdraw is refused with `402` (the master key is never used). - **Only successful (2xx) calls burn credit** — a failed/`4xx`/`5xx` call is refunded, so users pay for value, not errors. - **Every metered response carries `X-Pilot-Credits-Remaining`** (micro-dollars), diff --git a/internal/broker/broker.go b/internal/broker/broker.go index b059f33..559cea2 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -137,7 +137,7 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "provision: " + err.Error()}) return } - creditCost = app.costForPath(mpath) + 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()}) diff --git a/internal/broker/registry.go b/internal/broker/registry.go index 7427186..46bceb4 100644 --- a/internal/broker/registry.go +++ b/internal/broker/registry.go @@ -107,32 +107,65 @@ const ( // "/v1/calls/{id}" allowed, matched the same way as the allow-list); a path not // listed costs DefaultCost. type CreditSpec struct { - SeedCredits int `json:"seed_credits"` // per-caller starting balance in micro-$ (e.g. 5000000 = $5) - CostCredits map[string]int `json:"cost_credits"` // method-path → micro-$ debited per successful call - DefaultCost int `json:"default_cost"` // debit for paths not in CostCredits (default 1) + SeedCredits int `json:"seed_credits"` // per-caller starting balance in micro-$ (e.g. 5000000 = $5) + // CostCredits maps a method-path to the micro-$ debited per successful call. + // A key may be method-specific ("POST /v1/numbers") or any-method ("/v1/usage"); + // paths may be templated ("/v1/calls/{id}"). This matters when one path serves + // a free read and a paid write — e.g. GET /v1/numbers (list, free) vs + // 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) } -// costPattern is a templated cost key split into segments (like allowPatterns). +// costPattern is a templated cost key split into segments (like allowPatterns), +// optionally scoped to one HTTP method ("" = any method). type costPattern struct { - segs []string - cost int + method string + segs []string + cost int } // creditEnabled reports whether this app meters a per-caller micro-dollar budget. func (a *AppEntry) creditEnabled() bool { return a.creditSeed > 0 } -// costForPath returns the micro-dollar cost of a call to path: an exact cost -// entry wins, else the first matching templated pattern, else DefaultCost. -func (a *AppEntry) costForPath(path string) int { - if c, ok := a.creditExact[path]; ok { +// costKey is the exact-map key for a (method, path) cost entry ("" method = any). +func costKey(method, path string) string { return method + " " + path } + +// parseCostKey splits a cost_credits key into (method, path): "POST /v1/x" → +// ("POST","/v1/x"); a bare "/v1/x" (leading slash) → ("","/v1/x") = any method. +func parseCostKey(k string) (method, path string) { + if i := strings.IndexByte(k, ' '); i > 0 && k[0] != '/' { + return k[:i], k[i+1:] + } + return "", k +} + +// costForCall returns the micro-$ cost of a METHOD call to path. Resolution order: +// method-specific exact → any-method exact → method-specific pattern → any-method +// pattern → DefaultCost. +func (a *AppEntry) costForCall(method, path string) int { + if c, ok := a.creditExact[costKey(method, path)]; ok { + return c + } + if c, ok := a.creditExact[costKey("", path)]; ok { return c } if len(a.creditPatterns) > 0 { segs := strings.Split(path, "/") + anyCost, anyOK := 0, false for _, p := range a.creditPatterns { - if segmentsMatch(p.segs, segs) { - return p.cost + if !segmentsMatch(p.segs, segs) { + continue + } + if p.method == method { + return p.cost // method-specific wins immediately } + if p.method == "" && !anyOK { + anyCost, anyOK = p.cost, true + } + } + if anyOK { + return anyCost } } return a.creditDefault @@ -254,17 +287,21 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) { return nil, fmt.Errorf("registry: app %s: `credit` (HTTP budget) and `provision` (cloud) are mutually exclusive", a.ID) } a.creditSeed = a.Credit.SeedCredits + // default_cost 0 is meaningful: unlisted paths are FREE (e.g. reads), + // so only explicitly-priced method-paths debit. A negative value is a + // typo — clamp to 0. a.creditDefault = a.Credit.DefaultCost - if a.creditDefault <= 0 { - a.creditDefault = defaultCostCredits + if a.creditDefault < 0 { + a.creditDefault = 0 } a.creditExact = map[string]int{} a.creditPatterns = nil - for p, c := range a.Credit.CostCredits { - if strings.Contains(p, "{") { - a.creditPatterns = append(a.creditPatterns, costPattern{segs: strings.Split(p, "/"), cost: c}) + for k, c := range a.Credit.CostCredits { + method, path := parseCostKey(k) + if strings.Contains(path, "{") { + a.creditPatterns = append(a.creditPatterns, costPattern{method: method, segs: strings.Split(path, "/"), cost: c}) } else { - a.creditExact[p] = c + a.creditExact[costKey(method, path)] = c } } } diff --git a/internal/broker/zz_credit_gate_test.go b/internal/broker/zz_credit_gate_test.go index 316988d..92ab002 100644 --- a/internal/broker/zz_credit_gate_test.go +++ b/internal/broker/zz_credit_gate_test.go @@ -105,3 +105,44 @@ func TestCreditGate_MutuallyExclusiveWithProvision(t *testing.T) { t.Fatal("expected credit+provision to be rejected") } } + +// TestCreditGate_MethodSpecificCost: one path can be a free read AND a paid write +// (GET /v1/numbers list = free, POST /v1/numbers buy = $3). A method-scoped cost +// key prices only the write; the GET is not charged. +func TestCreditGate_MethodSpecificCost(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(up.Close) + reg, err := ParseRegistry([]byte(`[{ + "id":"io.pilot.test","upstream":"`+up.URL+`","key_env":"TEST_KEY", + "auth_header":"Authorization","auth_scheme":"Bearer","allow":["/v1/numbers"], + "credit":{"seed_credits":5000000,"default_cost":0,"cost_credits":{"POST /v1/numbers":3000000}} + }]`), 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) + + do := func(method string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + b.ServeHTTP(rec, signedReq(t, priv, method, "/io.pilot.test/v1/numbers", []byte(`{}`), now)) + return rec + } + // GET is a free read → balance unchanged at 5000000. + if rec := do("GET"); rec.Code != 200 || rec.Header().Get("X-Pilot-Credits-Remaining") != "5000000" { + t.Fatalf("GET /v1/numbers: %d remaining %q, want 200/5000000 (free read)", rec.Code, rec.Header().Get("X-Pilot-Credits-Remaining")) + } + // POST buys a number → debit $3 → remaining $2. + if rec := do("POST"); rec.Code != 200 || rec.Header().Get("X-Pilot-Credits-Remaining") != "2000000" { + t.Fatalf("POST /v1/numbers: %d remaining %q, want 200/2000000 ($3 debited)", rec.Code, rec.Header().Get("X-Pilot-Credits-Remaining")) + } + // A second $3 buy can't fit in the remaining $2 → 402. + if rec := do("POST"); rec.Code != http.StatusPaymentRequired { + t.Fatalf("second POST: %d, want 402", rec.Code) + } +}