Skip to content
Closed
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
22 changes: 16 additions & 6 deletions internal/x402/authcapture.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
Expand Down
22 changes: 15 additions & 7 deletions internal/x402/authcapture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -211,18 +213,20 @@ 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,
})
if err != nil {
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)
Expand Down Expand Up @@ -345,18 +349,20 @@ 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,
})
if err != nil {
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)
Expand Down Expand Up @@ -510,18 +516,20 @@ 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,
})
if err != nil {
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 {
Expand Down
15 changes: 8 additions & 7 deletions internal/x402/authgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
78 changes: 78 additions & 0 deletions internal/x402/unlock_agent_default_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 35 additions & 3 deletions internal/x402/unlockgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading