diff --git a/internal/x402/authcapture.go b/internal/x402/authcapture.go index 1c18e9dab..3cf4d6511 100644 --- a/internal/x402/authcapture.go +++ b/internal/x402/authcapture.go @@ -14,11 +14,16 @@ const ( defaultRefundDeadlineSecs = 1800 ) -// AuthCaptureUnlockConfig configures the single offer whose SIWX session is -// minted only after an auth-capture payment verifies and settles. +// AuthCaptureUnlockConfig configures the paid sign-in applied to AGENT offers: +// their SIWX session is minted only after an auth-capture payment verifies and +// settles. Which offers it covers is decided by type (see isUnlockOffer), not +// by config — this struct only carries how the payment is priced and split. +// +// Price, Network and PayTo are OVERRIDES. Left empty they fall back to the +// agent offer's own values, so a stack with several agents needs no per-agent +// configuration and each seller is paid to their own address. type AuthCaptureUnlockConfig struct { Enabled bool `yaml:"enabled"` - OfferPrefix string `yaml:"offerPrefix"` Price string `yaml:"price"` Network string `yaml:"network"` PayTo string `yaml:"payTo"` @@ -45,15 +50,20 @@ func (c *AuthCaptureUnlockConfig) Validate() error { } if c.Enabled { - if c.OfferPrefix == "" { - return fmt.Errorf("offerPrefix must be non-empty when enabled") - } if c.FeeRecipient == "" { return fmt.Errorf("feeRecipient must be non-empty when enabled") } if c.CaptureAuthorizer == "" { return fmt.Errorf("captureAuthorizer must be non-empty when enabled") } + // Price is resolved per-offer from the agent's own price, so it is + // deliberately NOT required here — but if it never resolves, the + // requirement builder would advertise an empty amount. handlePaidUnlock + // fills it from the rule before calling Validate's caller, so an empty + // price at this point means neither config nor offer priced it. + if c.Price == "" { + return fmt.Errorf("price is empty and the offer does not declare one") + } } if c.MinFeeBps > c.MaxFeeBps { return fmt.Errorf("minFeeBps %d exceeds maxFeeBps %d", c.MinFeeBps, c.MaxFeeBps) diff --git a/internal/x402/authcapture_test.go b/internal/x402/authcapture_test.go index 78f7e4b86..f9f0778f0 100644 --- a/internal/x402/authcapture_test.go +++ b/internal/x402/authcapture_test.go @@ -20,12 +20,14 @@ const ( testFeeRecipient = "0x1111111111111111111111111111111111111111" testCaptureAuthorizer = "0x2222222222222222222222222222222222222222" testUnlockPayer = "0xAbCdEfabcdefABCDefAbcdefabCDefABcDefAbCd" + // Unlock offers are now selected by offer TYPE, not by a configured + // path, so this is just where the fixture agent happens to live. + testOfferPrefix = "/services/agent" ) func validAuthCaptureConfig() AuthCaptureUnlockConfig { return AuthCaptureUnlockConfig{ Enabled: true, - OfferPrefix: "/services/agent", Price: "1.00", FeeRecipient: testFeeRecipient, MinFeeBps: 100, @@ -211,10 +213,12 @@ func TestPaidUnlock_InlineFirstMessage(t *testing.T) { Routes: []RouteRule{{ Pattern: "/services/agent/*", Gate: "auth", - StripPrefix: unlockConfig.OfferPrefix, + StripPrefix: testOfferPrefix, UpstreamURL: upstream.URL, OfferNamespace: "test", OfferName: "agent", + // Selects the unlock gate: isUnlockOffer keys on the agent type. + AgentRuntime: "hermes", }}, AuthCaptureUnlock: &unlockConfig, }) @@ -222,7 +226,7 @@ func TestPaidUnlock_InlineFirstMessage(t *testing.T) { t.Fatalf("NewVerifier: %v", err) } - path := unlockConfig.OfferPrefix + "/chat" + path := testOfferPrefix + "/chat" req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() v.HandleProxy(w, req) @@ -345,10 +349,12 @@ func TestPaidUnlock_SettleErrorSurfacesTxHash(t *testing.T) { Routes: []RouteRule{{ Pattern: "/services/agent/*", Gate: "auth", - StripPrefix: unlockConfig.OfferPrefix, + StripPrefix: testOfferPrefix, UpstreamURL: "http://upstream.invalid", OfferNamespace: "test", OfferName: "agent", + // Selects the unlock gate: isUnlockOffer keys on the agent type. + AgentRuntime: "hermes", }}, AuthCaptureUnlock: &unlockConfig, }) @@ -356,7 +362,7 @@ func TestPaidUnlock_SettleErrorSurfacesTxHash(t *testing.T) { t.Fatalf("NewVerifier: %v", err) } - path := unlockConfig.OfferPrefix + "/chat" + path := testOfferPrefix + "/chat" challengeReq := httptest.NewRequest(http.MethodGet, path, nil) cw := httptest.NewRecorder() v.HandleProxy(cw, challengeReq) @@ -510,10 +516,12 @@ func TestPaidUnlock_RejectsTamperedPayment(t *testing.T) { Routes: []RouteRule{{ Pattern: "/services/agent/*", Gate: "auth", - StripPrefix: unlockConfig.OfferPrefix, + StripPrefix: testOfferPrefix, UpstreamURL: upstream.URL, OfferNamespace: "test", OfferName: "agent", + // Selects the unlock gate: isUnlockOffer keys on the agent type. + AgentRuntime: "hermes", }}, AuthCaptureUnlock: &unlockConfig, }) @@ -521,7 +529,7 @@ func TestPaidUnlock_RejectsTamperedPayment(t *testing.T) { t.Fatalf("NewVerifier: %v", err) } - path := unlockConfig.OfferPrefix + "/chat" + path := testOfferPrefix + "/chat" cw := httptest.NewRecorder() v.HandleProxy(cw, httptest.NewRequest(http.MethodGet, path, nil)) var challenge struct { diff --git a/internal/x402/authgate.go b/internal/x402/authgate.go index 96e110d78..d60ee25a8 100644 --- a/internal/x402/authgate.go +++ b/internal/x402/authgate.go @@ -282,18 +282,19 @@ func (v *Verifier) handleAuthEndpoints(w http.ResponseWriter, r *http.Request) b return false } cfg := v.config.Load() - if cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled && - strings.TrimSuffix(prefix, "/") == strings.TrimSuffix(cfg.AuthCaptureUnlock.OfferPrefix, "/") { - // ponytail: unlock-gated offer: paid /unlock is the only mint path; free SIWX signin - // suppressed here. Ceiling: one unlock offer (global config); per-offer via CRD is the - // follow-up. - return false - } // Only offers that actually declare an auth route get sign-in // endpoints — everything else falls through to normal route matching // (and its fail-closed handling). rule := v.authRuleForPrefix(prefix) + + if v.isUnlockOffer(cfg, rule) { + // Unlock-gated offer: paid /unlock is the only mint path, so the free + // SIWX sign-in is suppressed here. Selection is by offer type — see + // isUnlockOffer — so this now covers every agent offer rather than a + // single configured prefix. + return false + } if rule == nil { return false } diff --git a/internal/x402/unlock_agent_default_test.go b/internal/x402/unlock_agent_default_test.go new file mode 100644 index 000000000..edd012b95 --- /dev/null +++ b/internal/x402/unlock_agent_default_test.go @@ -0,0 +1,78 @@ +package x402 + +import "testing" + +// TestIsUnlockOffer_SelectsAgentsOnly pins the product rule: the paid unlock +// (and its platform fee split) applies to AGENT offers and only agent offers. +// +// An agent is sold as a conversation a human opens in the chat widget — +// connect wallet, pay once, then per-turn billing — so paid sign-in belongs to +// the type. http offers are machine-to-machine APIs with no session concept +// and must stay on plain per-request `exact` payments. +// +// This replaces selection by a configured offerPrefix, which capped a stack at +// one unlock offer and required the operator to opt in by hand. +func TestIsUnlockOffer_SelectsAgentsOnly(t *testing.T) { + enabled := validAuthCaptureConfig() + cfg := &PricingConfig{AuthCaptureUnlock: &enabled} + + agent := &RouteRule{StripPrefix: "/services/analyst", AgentRuntime: "hermes"} + httpOffer := &RouteRule{StripPrefix: "/services/kalshi-intel"} + + var v Verifier + if !v.isUnlockOffer(cfg, agent) { + t.Error("agent offer must be unlock-gated — the fee ships with the product, not per-offer opt-in") + } + if v.isUnlockOffer(cfg, httpOffer) { + t.Error("http offer must NOT be unlock-gated — gating it would tax per-request API traffic") + } + + // Every agent on the stack is covered, not just one: the old offerPrefix + // ceiling is gone. + second := &RouteRule{StripPrefix: "/services/auditor", AgentRuntime: "hermes"} + if !v.isUnlockOffer(cfg, second) { + t.Error("a second agent offer must also be unlock-gated (no one-offer-per-stack ceiling)") + } +} + +// TestIsUnlockOffer_RespectsDisableAndNils keeps the kill switch honest: an +// operator can still turn the gate off entirely, and a nil rule or config must +// never select the paid path. +func TestIsUnlockOffer_RespectsDisableAndNils(t *testing.T) { + off := validAuthCaptureConfig() + off.Enabled = false + agent := &RouteRule{StripPrefix: "/services/analyst", AgentRuntime: "hermes"} + + var v Verifier + if v.isUnlockOffer(&PricingConfig{AuthCaptureUnlock: &off}, agent) { + t.Error("disabled config must not gate anything") + } + if v.isUnlockOffer(&PricingConfig{}, agent) { + t.Error("absent authCaptureUnlock must not gate anything") + } + if v.isUnlockOffer(nil, agent) { + t.Error("nil config must not gate anything") + } + enabled := validAuthCaptureConfig() + if v.isUnlockOffer(&PricingConfig{AuthCaptureUnlock: &enabled}, nil) { + t.Error("nil rule must not gate anything") + } +} + +// TestAuthCaptureUnlockConfig_PriceResolvedPerOffer guards the multi-agent +// correctness bit. Price, payTo and network are overrides now; with several +// agents on a stack they must fall back to each offer's own values, or every +// agent's unlock revenue lands in one wallet at one price. +func TestAuthCaptureUnlockConfig_PriceResolvedPerOffer(t *testing.T) { + c := validAuthCaptureConfig() + c.Price = "" + if err := c.Validate(); err == nil { + t.Error("an unlock with no price from config OR offer must be rejected, not advertised at zero") + } + + c = validAuthCaptureConfig() + c.Price = "0.01" + if err := c.Validate(); err != nil { + t.Errorf("priced config must validate: %v", err) + } +} diff --git a/internal/x402/unlockgate.go b/internal/x402/unlockgate.go index 22e337eb4..5df73ea76 100644 --- a/internal/x402/unlockgate.go +++ b/internal/x402/unlockgate.go @@ -18,10 +18,25 @@ import ( // inline auth-capture payment on its first request; handleAuthEndpoints // suppresses its free SIWX sign-in endpoints. -// isUnlockOffer reports whether rule is the configured auth-capture unlock offer. +// isUnlockOffer reports whether rule is an auth-capture unlock offer. +// +// Selection is by OFFER TYPE, not by a configured path: every agent offer is +// unlock-gated, http offers never are. An agent is sold as a conversation a +// human opens in the chat widget — connect wallet, pay once, then per-turn +// billing — so the paid sign-in belongs to the whole type. http offers are +// machine-to-machine APIs with no session concept, and gating them would tax +// per-request traffic that never signs in. +// +// Deriving this from the type rather than an operator-set offerPrefix is what +// makes the platform fee ship WITH the product: an operator does not opt in +// per offer, and there is no longer a one-unlock-offer-per-stack ceiling. +// +// rule.AgentRuntime is populated from the ServiceOffer in serviceoffer_source.go +// and is the same signal mergeAgentExtras already uses to decide a rule is an +// agent, so this adds no new plumbing. func (v *Verifier) isUnlockOffer(cfg *PricingConfig, rule *RouteRule) bool { return cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled && - strings.TrimSuffix(rule.StripPrefix, "/") == strings.TrimSuffix(cfg.AuthCaptureUnlock.OfferPrefix, "/") + rule != nil && rule.AgentRuntime != "" } // handlePaidUnlock runs the auth-capture pay->settle->mint flow for the unlock @@ -36,8 +51,25 @@ func (v *Verifier) handlePaidUnlock(w http.ResponseWriter, r *http.Request, rule } uc := *cfg.AuthCaptureUnlock + // Resolve per-offer fallbacks BEFORE validating: every agent offer is now + // unlock-gated, so price and the seller leg come from the OFFER unless the + // operator pinned them globally. Without this each agent's unlock revenue + // would land in one wallet at one price. Validate then sees the values that + // will actually be advertised. + if uc.Price == "" { + // One turn's worth buys the session. + uc.Price = rule.Price + } + if uc.PayTo == "" { + uc.PayTo = rule.PayTo + } + if uc.Network == "" { + uc.Network = rule.Network + } + if err := uc.Validate(); err != nil { - log.Printf("x402-verifier: auth-capture unlock config invalid: %v", err) + log.Printf("x402-verifier: auth-capture unlock config invalid for %s/%s: %v", + rule.OfferNamespace, rule.OfferName, err) writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) return }