From 6e027708cba6f59aa7c677d8386c7d9783881cf6 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 18 May 2026 10:01:26 +0530 Subject: [PATCH 1/4] refactor: centralize gateway paid request flow Co-authored-by: codex --- CONTEXT.md | 24 ++++++++ gateway/cache.go | 40 ++----------- gateway/errors_test.go | 63 +++++++++++++++++++++ gateway/main.go | 46 +-------------- gateway/payment_flow.go | 89 +++++++++++++++++++++++++++++ gateway/payment_flow_test.go | 106 +++++++++++++++++++++++++++++++++++ 6 files changed, 290 insertions(+), 78 deletions(-) create mode 100644 CONTEXT.md create mode 100644 gateway/payment_flow.go create mode 100644 gateway/payment_flow_test.go diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..21d31fc --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,24 @@ +# MicroAI Paygate Context + +## Domain Vocabulary + +**Payment Context** +The gateway-issued x402 signing payload for one paid AI request. It contains the recipient, token, amount, chain ID, nonce, and timestamp that the client signs with EIP-712 typed data. + +**Signed Retry** +The client request sent after a `402 Payment Required` challenge. It repeats the original AI request and includes `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp` headers from the Payment Context. + +**Verifier Result** +The Rust verifier's answer for a Signed Retry. A valid result includes the recovered wallet address; an invalid result includes a machine-readable `error_code` such as `invalid_signature`, `nonce_already_used`, or `chain_id_mismatch`. + +**Paid Request** +A gateway request that has passed x402 verification and can either call the AI provider or serve an already-cached AI result. A Paid Request is the point where receipt generation becomes valid. + +**Receipt** +The gateway-signed proof produced after a successful Paid Request. It records payment terms, payer, endpoint, request hash, response hash, and the gateway server public key. + +**Receipt Store** +The persistence module for signed receipts. Redis is the default adapter; memory storage is used for local quick starts and tests. + +**Response Cache** +The optional Redis-backed cache for AI summaries. A cache hit still requires a valid Signed Retry before the cached result can be returned. diff --git a/gateway/cache.go b/gateway/cache.go index 0fdefcb..298be3e 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -12,7 +12,6 @@ import ( "log" "net/http" "os" - "strconv" "sync" "time" @@ -118,51 +117,22 @@ func CacheMiddleware() gin.HandlerFunc { log.Printf("Cache HIT: %s", cacheKey) // Cache HIT! -> Verify Payment *BEFORE* serving - // verifyPayment creates its own timeout context, so pass request context directly - timestampStr := c.GetHeader("X-402-Timestamp") - if timestampStr == "" { - respondError(c, 400, "invalid_timestamp", fmt.Errorf("missing X-402-Timestamp header")) - c.Abort() - return - } - timestamp, err := strconv.ParseUint(timestampStr, 10, 64) - if err != nil || timestamp == 0 { - respondError(c, 400, "invalid_timestamp", fmt.Errorf("invalid X-402-Timestamp header")) - c.Abort() - return - } - verifyResp, paymentCtx, err := verifyPayment(c.Request.Context(), signature, nonce, timestamp) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - respondError(c, 504, "verifier_timeout", err) - } else { - respondError(c, 502, "verification_unavailable", err) - } - c.Abort() - return - } - - if !verifyResp.IsValid { - respondVerificationFailure(c, verifyResp) - c.Abort() - return - } - if verifyResp.RecoveredAddress == "" { - respondError(c, 502, "verification_unavailable", fmt.Errorf("verifier success missing recovered_address")) + verified, ok := verifyPaidRequest(c) + if !ok { c.Abort() return } // Payment Verified. Store verification for downstream if needed (though we abort) - c.Set("payment_verification", verifyResp) - c.Set("payment_context", paymentCtx) + c.Set("payment_verification", verified) + c.Set("payment_context", &verified.PaymentContext) // Generate Receipt and Respond // We treat the cached result as the AI result // Generate receipt for cache hit using current request and cached result. // Note: request_hash matches current request, response is from cache, // but both are cryptographically valid since cache key ensures identical text. - if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil { + if err := sendPaidResult(c, verified, requestBody, cached.Result); err != nil { log.Printf("Failed to send cached response receipt: %v", err) // generateAndSendReceipt already sent an error response (500) } diff --git a/gateway/errors_test.go b/gateway/errors_test.go index d1f1703..b42004c 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -207,6 +207,69 @@ func TestCacheHitMapsVerifierNonceReplay(t *testing.T) { require.Equal(t, "test-correlation-id", response["correlation_id"]) } +func TestCacheHitMapsVerifierTimeout(t *testing.T) { + text := "cached timeout text" + withCachedSummary(t, text) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(1500 * time.Millisecond) + })) + t.Cleanup(server.Close) + t.Setenv("VERIFIER_URL", server.URL) + t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1") + + router := newCachedSummarizeTestRouter() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"`+text+`"}`)) + + require.Equal(t, http.StatusGatewayTimeout, recorder.Code) + + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "verifier_timeout", response["error"]) + require.Equal(t, "test-correlation-id", response["correlation_id"]) +} + +func TestCacheHitMapsVerifierUnavailable(t *testing.T) { + text := "cached unavailable text" + withCachedSummary(t, text) + withVerifierResponse(t, http.StatusInternalServerError, `SENSITIVE_VERIFIER_FAILURE`) + + router := newCachedSummarizeTestRouter() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"`+text+`"}`)) + + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.NotContains(t, recorder.Body.String(), "SENSITIVE_VERIFIER_FAILURE") + + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "verification_unavailable", response["error"]) + require.Equal(t, "test-correlation-id", response["correlation_id"]) +} + +func TestCacheHitSuccessEmitsReceiptHeader(t *testing.T) { + text := "cached success text" + withCachedSummary(t, text) + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0xabc","error":""}`) + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + resetServerPrivateKeyForTest(t) + + origStore := getActiveReceiptStore() + setActiveReceiptStore(NewInMemoryReceiptStore()) + t.Cleanup(func() { setActiveReceiptStore(origStore) }) + + router := newCachedSummarizeTestRouter() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"`+text+`"}`)) + + require.Equal(t, http.StatusOK, recorder.Code) + require.NotEmpty(t, recorder.Header().Get("X-402-Receipt")) + + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "cached summary", response["result"]) +} + func TestHandleSummarizePreservesAIProviderTimeoutStatus(t *testing.T) { origProvider := aiProvider t.Cleanup(func() { aiProvider = origProvider }) diff --git a/gateway/main.go b/gateway/main.go index 644f06a..3e12500 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -352,28 +352,8 @@ func handleSummarize(c *gin.Context) { var requestBody []byte var err error - signature := c.GetHeader("X-402-Signature") - nonce := c.GetHeader("X-402-Nonce") - timestampHeader := c.GetHeader("X-402-Timestamp") - - // Basic check - if signature == "" || nonce == "" { - c.JSON(402, gin.H{ - "error": "Payment Required", - "message": "Please sign the payment context", - "paymentContext": createPaymentContext(), - }) - return - } - - if timestampHeader == "" { - respondError(c, 400, "invalid_timestamp", fmt.Errorf("missing X-402-Timestamp header")) - return - } - - timestampValue, err := strconv.ParseUint(timestampHeader, 10, 64) - if err != nil || timestampValue == 0 { - respondError(c, 400, "invalid_timestamp", fmt.Errorf("invalid X-402-Timestamp header")) + verified, ok := verifyPaidRequest(c) + if !ok { return } @@ -400,26 +380,6 @@ func handleSummarize(c *gin.Context) { } } - // Verify - verifyResp, paymentCtx, err := verifyPayment(c.Request.Context(), signature, nonce, uint64(timestampValue)) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - respondError(c, 504, "verifier_timeout", err) - } else { - respondError(c, 502, "verification_unavailable", err) - } - return - } - - if !verifyResp.IsValid { - respondVerificationFailure(c, verifyResp) - return - } - if verifyResp.RecoveredAddress == "" { - respondError(c, 502, "verification_unavailable", fmt.Errorf("verifier success missing recovered_address")) - return - } - // 2. Parse Request var req SummarizeRequest if err := json.Unmarshal(requestBody, &req); err != nil { @@ -445,7 +405,7 @@ func handleSummarize(c *gin.Context) { } // 4. Generate & Send Receipt - if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, summary); err != nil { + if err := sendPaidResult(c, verified, requestBody, summary); err != nil { log.Printf("Failed to generate receipt: %v", err) // generateAndSendReceipt sends error response if it fails? // No, it returns error, we might have already written status if we aren't careful. diff --git a/gateway/payment_flow.go b/gateway/payment_flow.go new file mode 100644 index 0000000..6bd25af --- /dev/null +++ b/gateway/payment_flow.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type verifiedPayment struct { + PaymentContext PaymentContext + RecoveredAddress string +} + +func verifyPaidRequest(c *gin.Context) (*verifiedPayment, bool) { + signature := c.GetHeader("X-402-Signature") + nonce := c.GetHeader("X-402-Nonce") + + if signature == "" || nonce == "" { + c.JSON(http.StatusPaymentRequired, gin.H{ + "error": "Payment Required", + "message": "Please sign the payment context", + "paymentContext": createPaymentContext(), + }) + return nil, false + } + + timestampValue, ok := paymentTimestamp(c) + if !ok { + return nil, false + } + + verifyResp, paymentCtx, err := verifyPayment(c.Request.Context(), signature, nonce, timestampValue) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + respondError(c, http.StatusGatewayTimeout, "verifier_timeout", err) + } else { + respondError(c, http.StatusBadGateway, "verification_unavailable", err) + } + return nil, false + } + + if verifyResp == nil { + respondError(c, http.StatusBadGateway, "verification_unavailable", fmt.Errorf("missing verifier response")) + return nil, false + } + if !verifyResp.IsValid { + respondVerificationFailure(c, verifyResp) + return nil, false + } + if verifyResp.RecoveredAddress == "" { + respondError(c, http.StatusBadGateway, "verification_unavailable", fmt.Errorf("verifier success missing recovered_address")) + return nil, false + } + + return &verifiedPayment{ + PaymentContext: *paymentCtx, + RecoveredAddress: verifyResp.RecoveredAddress, + }, true +} + +func paymentTimestamp(c *gin.Context) (uint64, bool) { + timestampHeader := c.GetHeader("X-402-Timestamp") + if timestampHeader == "" { + respondError(c, http.StatusBadRequest, "invalid_timestamp", fmt.Errorf("missing X-402-Timestamp header")) + return 0, false + } + + timestampValue, err := strconv.ParseUint(timestampHeader, 10, 64) + if err != nil || timestampValue == 0 { + respondError(c, http.StatusBadRequest, "invalid_timestamp", fmt.Errorf("invalid X-402-Timestamp header")) + return 0, false + } + + return timestampValue, true +} + +func sendPaidResult(c *gin.Context, payment *verifiedPayment, requestBody []byte, result string) error { + if payment == nil { + err := fmt.Errorf("missing verified payment") + respondError(c, http.StatusInternalServerError, "receipt_generation_failed", err) + return err + } + + return generateAndSendReceipt(c, payment.PaymentContext, payment.RecoveredAddress, requestBody, result) +} diff --git a/gateway/payment_flow_test.go b/gateway/payment_flow_test.go new file mode 100644 index 0000000..ffc79d2 --- /dev/null +++ b/gateway/payment_flow_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newPaymentFlowTestContext(req *http.Request) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = req + if correlationID := req.Header.Get("X-Correlation-ID"); correlationID != "" { + c.Set("correlation_id", correlationID) + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), CorrelationIDKey, correlationID)) + } + return c, recorder +} + +func TestVerifyPaidRequestWritesPaymentChallengeForMissingHeaders(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", strings.NewReader(`{"text":"hello"}`)) + c, recorder := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c) + + require.False(t, ok) + require.Nil(t, verified) + require.Equal(t, http.StatusPaymentRequired, recorder.Code) + + var response struct { + Error string `json:"error"` + Message string `json:"message"` + PaymentContext PaymentContext `json:"paymentContext"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "Payment Required", response.Error) + require.Equal(t, "Please sign the payment context", response.Message) + require.NotEmpty(t, response.PaymentContext.Recipient) + require.NotEmpty(t, response.PaymentContext.Token) + require.NotEmpty(t, response.PaymentContext.Amount) + require.NotEmpty(t, response.PaymentContext.Nonce) + require.Positive(t, response.PaymentContext.ChainID) + require.Positive(t, response.PaymentContext.Timestamp) +} + +func TestVerifyPaidRequestReturnsVerifiedPayment(t *testing.T) { + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0xabc","error":""}`) + req := signedSummarizeRequest(`{"text":"hello"}`) + c, recorder := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c) + + require.True(t, ok) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "0xabc", verified.RecoveredAddress) + require.Equal(t, "nonce-1", verified.PaymentContext.Nonce) + require.Equal(t, uint64(1700000000), verified.PaymentContext.Timestamp) +} + +func TestVerifyPaidRequestMapsVerifierTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(1500 * time.Millisecond) + })) + t.Cleanup(server.Close) + t.Setenv("VERIFIER_URL", server.URL) + t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1") + + req := signedSummarizeRequest(`{"text":"hello"}`) + c, recorder := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c) + + require.False(t, ok) + require.Nil(t, verified) + require.Equal(t, http.StatusGatewayTimeout, recorder.Code) + + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "verifier_timeout", response["error"]) + require.Equal(t, "test-correlation-id", response["correlation_id"]) +} + +func TestVerifyPaidRequestRequiresRecoveredAddress(t *testing.T) { + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"","error":""}`) + req := signedSummarizeRequest(`{"text":"hello"}`) + c, recorder := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c) + + require.False(t, ok) + require.Nil(t, verified) + require.Equal(t, http.StatusBadGateway, recorder.Code) + + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "verification_unavailable", response["error"]) + require.Equal(t, "test-correlation-id", response["correlation_id"]) +} From 881c6fcb7375c72edad8dcf56fd29963116b6929 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Tue, 19 May 2026 18:31:59 +0530 Subject: [PATCH 2/4] chore: address x402 flow review cleanup Co-authored-by: codex --- gateway/errors_test.go | 29 +++++++++++++++++------------ gateway/main.go | 3 --- gateway/payment_flow.go | 4 ---- gateway/payment_flow_test.go | 8 +------- 4 files changed, 18 insertions(+), 26 deletions(-) diff --git a/gateway/errors_test.go b/gateway/errors_test.go index b42004c..ead7ee9 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -61,6 +61,21 @@ func withVerifierResponse(t *testing.T, status int, body string) { t.Setenv("VERIFIER_URL", server.URL) } +func withSlowVerifier(t *testing.T) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(3 * time.Second): + } + })) + t.Cleanup(server.Close) + t.Setenv("VERIFIER_URL", server.URL) + t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1") +} + func withCachedSummary(t *testing.T, text string) { t.Helper() @@ -122,12 +137,7 @@ func TestHandleSummarizeSanitizesVerifierInvalidSignatureDetail(t *testing.T) { } func TestHandleSummarizeSanitizesVerifierTimeout(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(1500 * time.Millisecond) - })) - t.Cleanup(server.Close) - t.Setenv("VERIFIER_URL", server.URL) - t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1") + withSlowVerifier(t) router := newSummarizeTestRouter() recorder := httptest.NewRecorder() @@ -210,12 +220,7 @@ func TestCacheHitMapsVerifierNonceReplay(t *testing.T) { func TestCacheHitMapsVerifierTimeout(t *testing.T) { text := "cached timeout text" withCachedSummary(t, text) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(1500 * time.Millisecond) - })) - t.Cleanup(server.Close) - t.Setenv("VERIFIER_URL", server.URL) - t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1") + withSlowVerifier(t) router := newCachedSummarizeTestRouter() recorder := httptest.NewRecorder() diff --git a/gateway/main.go b/gateway/main.go index 3e12500..6da9325 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -407,9 +407,6 @@ func handleSummarize(c *gin.Context) { // 4. Generate & Send Receipt if err := sendPaidResult(c, verified, requestBody, summary); err != nil { log.Printf("Failed to generate receipt: %v", err) - // generateAndSendReceipt sends error response if it fails? - // No, it returns error, we might have already written status if we aren't careful. - // Let's implement generateAndSendReceipt to handle sending response. return } } diff --git a/gateway/payment_flow.go b/gateway/payment_flow.go index 6bd25af..594b8df 100644 --- a/gateway/payment_flow.go +++ b/gateway/payment_flow.go @@ -43,10 +43,6 @@ func verifyPaidRequest(c *gin.Context) (*verifiedPayment, bool) { return nil, false } - if verifyResp == nil { - respondError(c, http.StatusBadGateway, "verification_unavailable", fmt.Errorf("missing verifier response")) - return nil, false - } if !verifyResp.IsValid { respondVerificationFailure(c, verifyResp) return nil, false diff --git a/gateway/payment_flow_test.go b/gateway/payment_flow_test.go index ffc79d2..c2a55f1 100644 --- a/gateway/payment_flow_test.go +++ b/gateway/payment_flow_test.go @@ -7,7 +7,6 @@ import ( "net/http/httptest" "strings" "testing" - "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -66,12 +65,7 @@ func TestVerifyPaidRequestReturnsVerifiedPayment(t *testing.T) { } func TestVerifyPaidRequestMapsVerifierTimeout(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(1500 * time.Millisecond) - })) - t.Cleanup(server.Close) - t.Setenv("VERIFIER_URL", server.URL) - t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1") + withSlowVerifier(t) req := signedSummarizeRequest(`{"text":"hello"}`) c, recorder := newPaymentFlowTestContext(req) From 018a76e97036c41cdd29ffb65da1000385ac9374 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Tue, 19 May 2026 18:37:13 +0530 Subject: [PATCH 3/4] fix: validate signed summarize bodies before payment verification Co-authored-by: codex --- gateway/errors_test.go | 37 +++++++++++++++++++++++++++++++++++++ gateway/main.go | 15 ++++++++++----- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/gateway/errors_test.go b/gateway/errors_test.go index ead7ee9..7bcb474 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -136,6 +137,42 @@ func TestHandleSummarizeSanitizesVerifierInvalidSignatureDetail(t *testing.T) { require.Equal(t, "test-correlation-id", response["correlation_id"]) } +func TestHandleSummarizeValidatesSignedBodyBeforeVerifier(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "malformed JSON", + body: `{"text":`, + }, + { + name: "empty text", + body: `{"text":""}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var verifierCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + verifierCalls.Add(1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"is_valid":true,"recovered_address":"0xabc","error":""}`)) + })) + t.Cleanup(server.Close) + t.Setenv("VERIFIER_URL", server.URL) + + router := newSummarizeTestRouter() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(tt.body)) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, verifierCalls.Load()) + }) + } +} + func TestHandleSummarizeSanitizesVerifierTimeout(t *testing.T) { withSlowVerifier(t) diff --git a/gateway/main.go b/gateway/main.go index 6da9325..3b19e69 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -347,13 +347,12 @@ func main() { // applied by middleware and returns appropriate HTTP errors (402, 403, 504, // 500) to the client. func handleSummarize(c *gin.Context) { - // 1. Payment Verification // Note: CacheMiddleware aborts on cache HIT, so this handler only runs on cache MISS or when caching is disabled var requestBody []byte var err error - verified, ok := verifyPaidRequest(c) - if !ok { + if c.GetHeader("X-402-Signature") == "" || c.GetHeader("X-402-Nonce") == "" { + verifyPaidRequest(c) return } @@ -393,7 +392,13 @@ func handleSummarize(c *gin.Context) { return } - // 3. Call AI Service + // 3. Payment Verification + verified, ok := verifyPaidRequest(c) + if !ok { + return + } + + // 4. Call AI Service summary, err := aiProvider.Generate(c.Request.Context(), req.Text) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) { @@ -404,7 +409,7 @@ func handleSummarize(c *gin.Context) { return } - // 4. Generate & Send Receipt + // 5. Generate & Send Receipt if err := sendPaidResult(c, verified, requestBody, summary); err != nil { log.Printf("Failed to generate receipt: %v", err) return From 85f3af1d19fc171fb84c44ecaa43358521cf1226 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Tue, 19 May 2026 19:10:00 +0530 Subject: [PATCH 4/4] test: cover oversized signed summarize rejection Co-authored-by: codex --- gateway/errors_test.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/gateway/errors_test.go b/gateway/errors_test.go index 7bcb474..43de6fe 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -139,16 +139,24 @@ func TestHandleSummarizeSanitizesVerifierInvalidSignatureDetail(t *testing.T) { func TestHandleSummarizeValidatesSignedBodyBeforeVerifier(t *testing.T) { tests := []struct { - name string - body string + name string + body string + wantStatus int }{ { - name: "malformed JSON", - body: `{"text":`, + name: "malformed JSON", + body: `{"text":`, + wantStatus: http.StatusBadRequest, }, { - name: "empty text", - body: `{"text":""}`, + name: "empty text", + body: `{"text":""}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "oversized body", + body: strings.Repeat("x", 10*1024*1024+1), + wantStatus: http.StatusRequestEntityTooLarge, }, } @@ -167,7 +175,7 @@ func TestHandleSummarizeValidatesSignedBodyBeforeVerifier(t *testing.T) { recorder := httptest.NewRecorder() router.ServeHTTP(recorder, signedSummarizeRequest(tt.body)) - require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, tt.wantStatus, recorder.Code) require.Zero(t, verifierCalls.Load()) }) }