Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ HEALTH_CHECK_TIMEOUT_SECONDS=2
METRICS_ENABLED=true
# Gateway Prometheus endpoint path (default: /metrics)
METRICS_PATH=/metrics
# Log output format. Set to "json" for one structured JSON line per request
# (status, latency, correlation_id, cache/payment status, sanitized
# internal_error) suitable for log collectors. Any other value (default) keeps
# the human-readable gin/text logs.
# LOG_FORMAT=json



Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ Core local variables live in [.env.example](.env.example). Production placeholde
| `METRICS_PATH` | Gateway | Gateway metrics path. Default `/metrics`; values without a leading slash are normalized. |
| `ALLOWED_ORIGINS` | Gateway | Comma-separated CORS origins, no paths or query strings. |
| `TRUSTED_PROXIES` | Gateway | Comma-separated trusted proxy CIDRs for production IP handling. |
| `LOG_FORMAT` | Gateway | Set to `json` for one structured JSON log line per request (status, latency, correlation_id, cache/payment status, sanitized internal_error). Any other value (default) keeps human-readable text logs. |
| `NEXT_PUBLIC_GATEWAY_URL` | Web | Gateway base URL. Browser fetches `/api/ai/summarize` and `/api/receipts/:id` here. |
| `NEXT_PUBLIC_EXPECTED_CHAIN_ID` | Web | Chain ID the wallet widget targets. Must match the gateway's `CHAIN_ID`. Default `84532`. |
| `NEXT_PUBLIC_EXPECTED_CHAIN_NAME` | Web | Display name paired with the chain ID — used by the wallet widget's "Switch to <name>" button, hero headline, stat bar, and page title. Must be set when `CHAIN_ID` is overridden (e.g. `Base` for `8453`). |
Expand Down
1 change: 1 addition & 0 deletions gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Common optional variables:
| `CACHE_TTL_SECONDS` | `3600` | Response cache TTL. |
| `METRICS_ENABLED` | enabled unless set to `false` | Exposes Prometheus metrics. |
| `METRICS_PATH` | `/metrics` | Gateway metrics endpoint path. Missing leading slash is normalized. |
| `LOG_FORMAT` | text | Set to `json` for one structured JSON log line per request (status, latency, correlation_id, cache/payment status, sanitized internal_error). Any other value keeps human-readable text logs. |
| `RATE_LIMIT_ENABLED` | disabled unless set to `true` or `1` | Enables token bucket middleware. |
| `REQUEST_TIMEOUT_SECONDS` | `60` | Global request timeout. |
| `AI_REQUEST_TIMEOUT_SECONDS` | `30` | AI route timeout. |
Expand Down
42 changes: 35 additions & 7 deletions gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"time"

"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)

// CachedResponse represents the data stored in Redis
Expand Down Expand Up @@ -87,7 +88,9 @@ func CacheMiddleware() gin.HandlerFunc {
var req SummarizeRequest
if err := json.Unmarshal(requestBody, &req); err != nil {
// Invalid JSON - reject immediately to prevent cache bypass attacks
log.Printf("[DEBUG] Invalid JSON in request: %v", err)
if !jsonLogging {
log.Printf("[DEBUG] Invalid JSON in request: %v", err)
}
c.JSON(400, gin.H{"error": "Invalid request body", "message": "Request must be valid JSON"})
c.Abort()
return
Expand All @@ -114,8 +117,12 @@ func CacheMiddleware() gin.HandlerFunc {
cacheKey := getCacheKey(req.Text, model)

// Check Cache
if cached, err := getFromCache(c.Request.Context(), cacheKey); err == nil {
log.Printf("Cache HIT: %s", cacheKey)
cached, cacheErr := getFromCache(c.Request.Context(), cacheKey)
if cacheErr == nil {
if !jsonLogging {
log.Printf("Cache HIT: %s", cacheKey)
}
c.Set("cache_status", "hit")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record successful payments on cache hits

On a cache hit, this middleware verifies the payment and aborts before handleSummarize, but the success path only sets payment_verification and payment_context; therefore the new JSON entry reports cache_status=hit while omitting both payment_status and payer. Set those logging fields after successful cache-hit verification so payment telemetry is complete for both cached and uncached requests.

Useful? React with 👍 / 👎.


routePath := c.FullPath()
if routePath == "" {
Expand All @@ -141,7 +148,9 @@ func CacheMiddleware() gin.HandlerFunc {
if err != nil {

verificationTotal.WithLabelValues("error").Inc()
log.Printf("Verification error on cache hit: %v", err)
if !jsonLogging {
log.Printf("Verification error on cache hit: %v", sanitizeErrorString(err.Error()))
}

if errors.Is(err, context.DeadlineExceeded) {
respondError(c, 504, "verifier_timeout", err)
Expand All @@ -168,22 +177,41 @@ func CacheMiddleware() gin.HandlerFunc {
// Payment Verified. Store verification for downstream if needed (though we abort)
c.Set("payment_verification", verifyResp)
c.Set("payment_context", paymentCtx)
// Mirror handleSummarize so cache-hit responses carry complete payment
// telemetry; the structured logger only sees what we set on the context.
c.Set("payment_status", "success")
c.Set("payer", verifyResp.RecoveredAddress)

// 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 {
log.Printf("Failed to send cached response receipt: %v", err)
if !jsonLogging {
log.Printf("Failed to send cached response receipt: %v", sanitizeErrorString(err.Error()))
}
// generateAndSendReceipt already sent an error response (500)
}
c.Abort()
return
}

// Cache MISS
log.Printf("Cache MISS: %s", cacheKey)
// Cache MISS (or a backend failure we treat as a bypass). Distinguish a
// genuine absent key (redis.Nil) from connection errors / corrupt cached
// data so telemetry doesn't mask Redis outages as ordinary misses.
cacheStatus := "miss"
if !errors.Is(cacheErr, redis.Nil) {
cacheStatus = "error"
}
if !jsonLogging {
if cacheStatus == "error" {
log.Printf("Cache lookup error (treating as bypass): %s: %v", cacheKey, sanitizeErrorString(cacheErr.Error()))
} else {
log.Printf("Cache MISS: %s", cacheKey)
}
}
c.Set("cache_status", cacheStatus)

routePath := c.FullPath()
if routePath == "" {
Expand Down
41 changes: 38 additions & 3 deletions gateway/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,55 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strings"

"github.com/gin-gonic/gin"
)

var (
// Match an optional 0x prefix so real private keys (which are usually
// 0x-prefixed) are caught — a leading \b fails between "x" and the first
// hex digit because both are word characters. The trailing \b avoids
// chopping into longer hex runs.
hex64Regex = regexp.MustCompile(`(?:0x)?[0-9a-fA-F]{64}\b`)
// Allow dashes so versioned keys like sk-or-v1-<secret> are redacted in
// full, not just the "sk-or-v1" prefix.
openRouterKeyRegex = regexp.MustCompile(`sk-or-[a-zA-Z0-9-]+`)
)

func sanitizeErrorString(s string) string {
// Redact API keys before hex so a key whose secret happens to contain a
// 64-hex run collapses to a single [redacted_api_key] token.
s = openRouterKeyRegex.ReplaceAllString(s, "[redacted_api_key]")
s = hex64Regex.ReplaceAllString(s, "[redacted_hex_64]")
return s
}

func respondError(c *gin.Context, code int, publicMsg string, internalErr error) {
correlationID := responseCorrelationID(c)
// Only mark the payment failed if an earlier stage hasn't already recorded
// an outcome. handleSummarize sets payment_status="success" once the
// verifier accepts the signature; a later receipt-generation error must not
// overwrite that and misreport a paid request as a payment failure.
if _, ok := c.Get("payment_status"); !ok {
c.Set("payment_status", "failed")
}
c.Set("payment_error", publicMsg)

var sanitizedErr string
if internalErr != nil {
sanitizedErr = sanitizeErrorString(internalErr.Error())
c.Set("internal_error", sanitizedErr)
}

correlationID := responseCorrelationID(c)
if internalErr != nil && !jsonLogging {
log.Printf(
"[ERROR] correlation_id=%s status=%d error=%s internal=%v",
"[ERROR] correlation_id=%s status=%d error=%s internal=%s",
correlationID,
code,
publicMsg,
internalErr,
sanitizedErr,
)
}

Expand Down
40 changes: 37 additions & 3 deletions gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,23 @@ func main() {
fmt.Println("[WARN] CHAIN_ID not set, using default: 84532(Base Sepolia)")
}

r := gin.Default()
initLogFormat()

var r *gin.Engine
if jsonLogging {
gin.SetMode(gin.ReleaseMode)
r = gin.New()
// Register the JSON logger BEFORE recovery so a downstream panic still
// unwinds back through the logger's post-c.Next() block and produces a
// structured entry for the recovered 500. Recovery uses a custom writer
// that redacts x402 payment headers (signature/nonce/timestamp), which
// the default recovery dump would otherwise leak in plaintext — a nonce
// may stay replayable until it is consumed.
r.Use(JSONLoggerMiddleware())
r.Use(gin.RecoveryWithWriter(redactedRecoveryWriter()))
} else {
r = gin.Default()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Restrict trusted proxies to prevent X-Forwarded-For spoofing.
// IP-based rate limiting relies on c.ClientIP(), which reads
Expand Down Expand Up @@ -438,6 +454,9 @@ func handleSummarize(c *gin.Context) {

// Basic check
if signature == "" || nonce == "" {
// Distinct status so dashboards can separate the normal unsigned x402
// challenge (the most frequent payment event) from unrelated 402s.
c.Set("payment_status", "required")
c.JSON(402, gin.H{
"error": "Payment Required",
"message": "Please sign the payment context",
Expand Down Expand Up @@ -505,6 +524,9 @@ func handleSummarize(c *gin.Context) {
return
}

c.Set("payment_status", "success")
c.Set("payer", verifyResp.RecoveredAddress)

verificationTotal.WithLabelValues("success").Inc()

// 2. Parse Request
Expand Down Expand Up @@ -533,7 +555,12 @@ func handleSummarize(c *gin.Context) {

// 4. Generate & Send Receipt
if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, summary); err != nil {
log.Printf("Failed to generate receipt: %v", err)
// generateAndSendReceipt already routes its failures through respondError
// (which records a sanitized internal_error), so this is just a
// human-readable echo for text-mode logs.
if !jsonLogging {
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.
Expand Down Expand Up @@ -941,7 +968,14 @@ func handleGetReceipt(c *gin.Context) {

receipt, exists, err := getReceiptWithContext(c.Request.Context(), id)
if err != nil {
log.Printf("Failed to retrieve receipt %s: %v", id, err)
if jsonLogging {
// The plaintext log is suppressed in JSON mode; record a sanitized
// internal_error on the context so the structured entry still
// explains the 500 (Redis / receipt-store outages stay diagnosable).
c.Set("internal_error", sanitizeErrorString(err.Error()))
} else {
log.Printf("Failed to retrieve receipt %s: %v", id, err)
}
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to retrieve receipt",
})
Expand Down
Loading
Loading