diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 545ffa5..1c8925a 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "math" "net/http" @@ -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 //. func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { appID, mpath, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") @@ -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}) diff --git a/internal/broker/registry.go b/internal/broker/registry.go index 0d0403a..15e229b 100644 --- a/internal/broker/registry.go +++ b/internal/broker/registry.go @@ -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 @@ -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), @@ -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 { diff --git a/internal/broker/zz_credit_respcost_test.go b/internal/broker/zz_credit_respcost_test.go index 53e361e..118f2a5 100644 --- a/internal/broker/zz_credit_respcost_test.go +++ b/internal/broker/zz_credit_respcost_test.go @@ -1,6 +1,7 @@ package broker import ( + "encoding/json" "net/http" "net/http/httptest" "strconv" @@ -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 diff --git a/submissions/io.pilot.orthogonal/submission.json b/submissions/io.pilot.orthogonal/submission.json index 3f2c72a..153738b 100644 --- a/submissions/io.pilot.orthogonal/submission.json +++ b/submissions/io.pilot.orthogonal/submission.json @@ -1,6 +1,6 @@ { "id": "io.pilot.orthogonal", - "version": "0.1.0", + "version": "0.1.1", "namespace": "orthogonal", "description": "Connect your agent to Orthogonal — one key fronting 58 paid APIs / 851 endpoints (lead & contact enrichment, email & phone finding, web & social scraping, AI search, company/people/jobs data, weather, voice). Describe a task in plain English and Orthogonal's router returns the exact APIs to call; execute any of them through a single run, billed at the real per-call price and metered against a per-user $5 budget. Discovery, pricing and balance calls are free.", "email": "apps@pilotprotocol.network", @@ -67,46 +67,16 @@ }, { "name": "orthogonal.balance", - "description": "Orthogonal account credit balance (string '$X.XX'). FREE, read-only. Note: your per-user $5 Pilot budget is metered separately by the broker and is surfaced in the X-Pilot-Credits-Remaining header on every call — that header, not this account balance, is your personal remaining budget.", + "description": "YOUR remaining per-user budget on this app — returned by the broker from its own ledger as '$X.XX' plus credits_remaining (micro-USD; $5 = 5000000) and credits_seed. FREE, read-only, no upstream call. This is your personal budget, seeded at $5 on first use and debited by your own runs; the shared provider account's balance is never exposed. The same figure is on the X-Pilot-Credits-Remaining header of every response.", "latency": "fast", "http": { "verb": "GET", "path": "/v1/credits/balance" }, "params": [] - }, - { - "name": "orthogonal.check", - "description": "Preflight the shared account: does it hold ≥ amountCents credits? (amountCents is in Orthogonal credit units = dollars × 100000, so $5 = 500000). FREE.", - "latency": "fast", - "http": { "verb": "POST", "path": "/v1/credits/check" }, - "params": [ - { "name": "amountCents", "type": "int", "required": true, "in": "body", "description": "dollars × 100000 (e.g. 500000 = $5)" } - ] - }, - { - "name": "orthogonal.transactions", - "description": "Account transaction ledger — purchases, API charges, bonuses, refunds — with amounts, direction and timestamps, paginated. FREE, read-only. Amounts are in the credits unit (dollars × 100000).", - "latency": "fast", - "http": { "verb": "GET", "path": "/v1/credits/transactions" }, - "params": [ - { "name": "limit", "type": "int", "required": false, "in": "query", "description": "page size (default 50)" }, - { "name": "offset", "type": "int", "required": false, "in": "query", "description": "page offset (default 0)" } - ] - }, - { - "name": "orthogonal.usage", - "description": "Per-call spend history plus totalSpent over a window (dollar strings), for the shared account. FREE, read-only.", - "latency": "fast", - "http": { "verb": "GET", "path": "/v1/credits/usage" }, - "params": [ - { "name": "days", "type": "int", "required": false, "in": "query", "description": "window size in days (default 30)" }, - { "name": "limit", "type": "int", "required": false, "in": "query", "description": "page size (default 50)" }, - { "name": "offset", "type": "int", "required": false, "in": "query", "description": "page offset (default 0)" } - ] } ], "listing": { "display_name": "Orthogonal", "tagline": "One key, 851 paid APIs — described in English, metered per user", - "app_description": "# Orthogonal — a catalog of paid tools and APIs, for your agent\n\nOrthogonal is an **API marketplace / meta-API**: a single key fronts **58 third-party APIs across 851 endpoints** — lead & contact enrichment, work-email and phone finding, web & social scraping, AI web search, company / people / jobs data, weather, voice/phone, email inboxes, and more. You never sign up for the underlying providers and you never juggle their keys — you describe what you need, and pay Orthogonal per call. This Pilot app wraps Orthogonal's control plane behind the managed-key broker, so **your agent gets one metered, keyless surface** and a **per-user $5 budget**.\n\n## The workflow: discover → price → execute\n\n1. **Discover — `orthogonal.search`** (the natural-language router ★). Describe the task in plain English, e.g. *\"find the work email and phone for a person given their name and company\"*, and get back the ranked APIs and endpoints that can do it, grouped by API with a relevance score. This is the \"which API do I need?\" endpoint — you don't have to know the catalog.\n2. **Price — `orthogonal.details`.** Pass an `{api, path}` and get the full request schema (every path/query/body param, with types and required flags) **and the exact price in dollars**. This is the authoritative price source — search and list return `null` prices.\n3. **Execute — `orthogonal.run`.** Call `{api, path, body?, query?}` and Orthogonal dispatches to the provider, returns the provider's data, and reports the exact `priceCents` charged. This is the **only call that costs money**.\n\n`orthogonal.integrate` and `orthogonal.list` round out discovery (code snippets and full-catalog browse), and `orthogonal.balance` / `.usage` / `.transactions` / `.check` expose the account ledger — all free.\n\n## What you can do (representative)\n\n- **Contact & lead enrichment** — apollo, contactout, company-enrich, peopledatalabs, coresignal, aviato, crustdata, ocean-io, influencers-club.\n- **Work-email & phone finding** — tomba, icypeas, contactout, company-enrich, hunter.\n- **Web & AI search** — serper, linkup, tavily, exa/perplexity.\n- **Web & social scraping** — olostep, serper-scrape, scrapecreators (107 endpoints), scrapegraphai, fiber (92 endpoints).\n- **Company / firmographic / jobs data** — predictleads, fantastic-jobs, openfunnel, edges, context-dev, brand-dev.\n- **Weather, voice/phone, email inboxes** — precip, agentphone, agentmail.\n\nRun `orthogonal.search` (or `orthogonal.list`) for the live, complete set.\n\n## Pricing — how you're billed (true to real usage)\n\n- **Only `orthogonal.run` costs money.** Every discovery, pricing, and account call is **free**.\n- Each run is billed the **target endpoint's real price**, debited from your **$5 per-user budget** in micro-dollars. The `priceCents` in the run response is the exact amount charged (real cents); `X-Pilot-Credits-Remaining` on every response is your remaining budget in micro-dollars ($5 = 5000000).\n- Prices span **$0.001 – $3.50**. Distribution across the 851 endpoints: **11 free, ~612 fixed-price, 104 \"dynamic\"** (priced only after the call). Common tiers: Basic $0.001–0.01, Standard $0.01–0.10, Premium $0.10–1.00. Cheap endpoints to start with: serper ($0.002), olostep / tomba / linkup ($0.01).\n- For a **known** cost up front, call `orthogonal.details` first. For **\"dynamic\"** endpoints the price is only knowable from the run response — so metering is done on the actual charged amount, and a single call may spend the last of your budget; after that, `orthogonal.run` returns **402** (the free discovery calls keep working).\n\n## Per-user budget & fair use\n\nEach Pilot user is seeded **$5 of credit** on first use, metered by the broker against their signed pilot identity. When the budget is exhausted, billable runs return 402. To keep the shared master account fair, the broker also enforces a **per-IP identity cap**: a small number of distinct pilot identities may claim a fresh $5 from any one network, so a depleted user can't farm new budgets by minting new identities. The Orthogonal account itself is the ultimate backstop — if it runs dry, runs return 402 until it's topped up.\n\n## Good to know\n\n- **Auth is fully managed.** The `orth_live_` master key lives only in the broker; the installed adapter is keyless and signs each request with your pilot identity. Nothing to configure.\n- **The account is shared** across Pilot users (no per-user sub-accounts on Orthogonal), so `orthogonal.balance` / `.usage` / `.transactions` report the **account-wide** figures — your **personal** remaining budget is the `X-Pilot-Credits-Remaining` header.\n- Errors surface verbatim: 402 insufficient credit, 404 unknown api/path, 5xx upstream provider error, 429 rate-limited (back off).\n- `orthogonal.help` is the self-describing discovery contract: it lists every method with params, cost note, and latency class.", + "app_description": "# Orthogonal — a catalog of paid tools and APIs, for your agent\n\nOrthogonal is an **API marketplace / meta-API**: a single key fronts **58 third-party APIs across 851 endpoints** — lead & contact enrichment, work-email and phone finding, web & social scraping, AI web search, company / people / jobs data, weather, voice/phone, email inboxes, and more. You never sign up for the underlying providers and you never juggle their keys — you describe what you need, and pay Orthogonal per call. This Pilot app wraps Orthogonal's control plane behind the managed-key broker, so **your agent gets one metered, keyless surface** and a **per-user $5 budget**.\n\n## The workflow: discover → price → execute\n\n1. **Discover — `orthogonal.search`** (the natural-language router ★). Describe the task in plain English, e.g. *\"find the work email and phone for a person given their name and company\"*, and get back the ranked APIs and endpoints that can do it, grouped by API with a relevance score. This is the \"which API do I need?\" endpoint — you don't have to know the catalog.\n2. **Price — `orthogonal.details`.** Pass an `{api, path}` and get the full request schema (every path/query/body param, with types and required flags) **and the exact price in dollars**. This is the authoritative price source — search and list return `null` prices.\n3. **Execute — `orthogonal.run`.** Call `{api, path, body?, query?}` and Orthogonal dispatches to the provider, returns the provider's data, and reports the exact `priceCents` charged. This is the **only call that costs money**.\n\n`orthogonal.integrate` and `orthogonal.list` round out discovery (code snippets and full-catalog browse), and `orthogonal.balance` shows YOUR remaining per-user budget — all free.\n\n## What you can do (representative)\n\n- **Contact & lead enrichment** — apollo, contactout, company-enrich, peopledatalabs, coresignal, aviato, crustdata, ocean-io, influencers-club.\n- **Work-email & phone finding** — tomba, icypeas, contactout, company-enrich, hunter.\n- **Web & AI search** — serper, linkup, tavily, exa/perplexity.\n- **Web & social scraping** — olostep, serper-scrape, scrapecreators (107 endpoints), scrapegraphai, fiber (92 endpoints).\n- **Company / firmographic / jobs data** — predictleads, fantastic-jobs, openfunnel, edges, context-dev, brand-dev.\n- **Weather, voice/phone, email inboxes** — precip, agentphone, agentmail.\n\nRun `orthogonal.search` (or `orthogonal.list`) for the live, complete set.\n\n## Pricing — how you're billed (true to real usage)\n\n- **Only `orthogonal.run` costs money.** Every discovery, pricing, and account call is **free**.\n- Each run is billed the **target endpoint's real price**, debited from your **$5 per-user budget** in micro-dollars. The `priceCents` in the run response is the exact amount charged (real cents); `X-Pilot-Credits-Remaining` on every response is your remaining budget in micro-dollars ($5 = 5000000).\n- Prices span **$0.001 – $3.50**. Distribution across the 851 endpoints: **11 free, ~612 fixed-price, 104 \"dynamic\"** (priced only after the call). Common tiers: Basic $0.001–0.01, Standard $0.01–0.10, Premium $0.10–1.00. Cheap endpoints to start with: serper ($0.002), olostep / tomba / linkup ($0.01).\n- For a **known** cost up front, call `orthogonal.details` first. For **\"dynamic\"** endpoints the price is only knowable from the run response — so metering is done on the actual charged amount, and a single call may spend the last of your budget; after that, `orthogonal.run` returns **402** (the free discovery calls keep working).\n\n## Per-user budget & fair use\n\nEach Pilot user is seeded **$5 of credit** on first use, metered by the broker against their signed pilot identity. When the budget is exhausted, billable runs return 402. To keep the shared master account fair, the broker also enforces a **per-IP identity cap**: a small number of distinct pilot identities may claim a fresh $5 from any one network, so a depleted user can't farm new budgets by minting new identities. The Orthogonal account itself is the ultimate backstop — if it runs dry, runs return 402 until it's topped up.\n\n## Good to know\n\n- **Auth is fully managed.** The `orth_live_` master key lives only in the broker; the installed adapter is keyless and signs each request with your pilot identity. Nothing to configure.\n- **You only ever see your own budget.** The provider account is shared (no per-user sub-accounts on Orthogonal), so the broker deliberately does **not** expose the account-wide balance/usage/ledger. `orthogonal.balance` is answered by the broker from its own per-user ledger — you get your personal remaining budget (also on the `X-Pilot-Credits-Remaining` header), never the pooled account total.\n- Errors surface verbatim: 402 insufficient credit, 404 unknown api/path, 5xx upstream provider error, 429 rate-limited (back off).\n- `orthogonal.help` is the self-describing discovery contract: it lists every method with params, cost note, and latency class.", "categories": ["data", "search", "enrichment", "scraping", "ai"], "keywords": ["orthogonal", "api-marketplace", "enrichment", "lead-enrichment", "email-finder", "scraping", "web-search", "people-data", "company-data", "meta-api", "natural-language"], "license": "MIT",