Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ RECEIPT_TTL=86400
# Signature expiry configuration
# Chain ID enforced by the verifier (must match CHAIN_ID)
EXPECTED_CHAIN_ID=84532
# Reject legacy v1 authorization by default. Set to 1 only for an explicit rollback window.
MIN_AUTHORIZATION_VERSION=2

# Signature expiry window in seconds (default: 300 = 5 minutes)
SIGNATURE_EXPIRY_SECONDS=300
Expand All @@ -54,10 +56,9 @@ VERIFIER_REDIS_TIMEOUT_MS=2000

# Service URLs (for Docker/production)
VERIFIER_URL=http://127.0.0.1:3002
# Public gateway origin reserved for the request-bound authorization v2 cutover.
# Required trusted public gateway origin used in request-bound authorization.
# Set only the origin (scheme + host + optional port), with no path/query/fragment.
# The current v1 gateway does not read this value yet.
# PAYGATE_AUDIENCE=http://localhost:3000
PAYGATE_AUDIENCE=http://localhost:3000

# Maximum allowed request body size in bytes for verifier
# Default: 1048576 (1MB)
Expand Down
4 changes: 2 additions & 2 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ SERVER_WALLET_PRIVATE_KEY=<your-demo-server-wallet-private-key>
# The gateway normalizes this on startup, but storing it canonically up
# front avoids the warning log on every boot.
RECIPIENT_ADDRESS=<your-base-sepolia-recipient-address>
# Public gateway origin for request-bound authorization v2. Configure this now;
# it becomes enforced when the gateway v2 cutover is deployed.
# Required trusted public gateway origin for request-bound authorization v2.
PAYGATE_AUDIENCE=https://<gateway-app>.onrender.com
PAYMENT_AMOUNT=0.001

Expand All @@ -39,6 +38,7 @@ CHAIN_ID=84532
EXPECTED_CHAIN_ID=84532
SIGNATURE_EXPIRY_SECONDS=300
SIGNATURE_CLOCK_SKEW_SECONDS=60
MIN_AUTHORIZATION_VERSION=2
RECEIPT_STORE=redis
CACHE_ENABLED=false
REQUEST_TIMEOUT_SECONDS=60
Expand Down
27 changes: 8 additions & 19 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,49 +39,38 @@ jobs:
e2e:
runs-on: ubuntu-latest
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_API_KEY: ""
OPENROUTER_MODEL: ${{ vars.OPENROUTER_MODEL || 'z-ai/glm-4.5-air:free' }}
RECEIPT_STORE: memory
CACHE_ENABLED: "false"
PAYGATE_AUDIENCE: http://localhost:3000
MIN_AUTHORIZATION_VERSION: "2"
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Check OpenRouter key
run: |
if [ -z "$OPENROUTER_API_KEY" ]; then
echo "OPENROUTER_API_KEY not set; skipping E2E."
exit 0
fi

- name: Set up Go
if: env.OPENROUTER_API_KEY != ''
uses: actions/setup-go@v6
with:
go-version: '1.24.x'

- name: Set up Rust
if: env.OPENROUTER_API_KEY != ''
uses: dtolnay/rust-toolchain@stable

- name: Set up Bun
if: env.OPENROUTER_API_KEY != ''
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.13'

- name: Install JS deps
if: env.OPENROUTER_API_KEY != ''
run: bun install
run: bun install --frozen-lockfile

- name: Prep Go/Rust deps
if: env.OPENROUTER_API_KEY != ''
run: |
go mod download
cargo fetch
go mod download -C gateway
Comment thread
AnkanMisra marked this conversation as resolved.
cargo fetch --manifest-path verifier/Cargo.toml

- name: Run E2E (requires API key)
if: env.OPENROUTER_API_KEY != ''
- name: Run deterministic E2E
run: |
chmod +x ./run_e2e.sh
timeout 150 ./run_e2e.sh
timeout 300 ./run_e2e.sh
Comment thread
AnkanMisra marked this conversation as resolved.
Outdated
3 changes: 2 additions & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ The verifier is stateless EIP-712 signature recovery. Public on Render's free ti
- `VERIFIER_NONCE_STORE=redis`
- `VERIFIER_NONCE_KEY_PREFIX=microai:verifier:nonce:`
- `VERIFIER_REDIS_TIMEOUT_MS=2000`
- `MIN_AUTHORIZATION_VERSION=2`
Comment thread
AnkanMisra marked this conversation as resolved.
Outdated

4. Click **Deploy Web Service**. First Rust build takes ~3–5 min.
5. Copy the assigned public URL — e.g. `https://microai-verifier.onrender.com`. The gateway needs this URL in the next step.
Expand Down Expand Up @@ -114,8 +115,8 @@ RECIPIENT_ADDRESS=<your-eip55-checksummed-address>
REDIS_URL=<your-upstash-rediss-url>
RECEIPT_STORE=redis
VERIFIER_URL=https://<verifier-app>.onrender.com
PAYGATE_AUDIENCE=https://<gateway-app>.onrender.com
Comment thread
coderabbitai[bot] marked this conversation as resolved.
CHAIN_ID=84532
EXPECTED_CHAIN_ID=84532
PAYMENT_AMOUNT=0.001
ALLOWED_ORIGINS=*
Comment thread
AnkanMisra marked this conversation as resolved.
Outdated
TRUSTED_PROXIES=0.0.0.0/0
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,7 @@ sequenceDiagram
G-->>C: 200 result + X-402-Receipt
```

The signed context binds the recipient, token, amount, nonce, timestamp, and chain ID. The verifier rejects malformed signatures, wrong chains, stale or future timestamps, and replayed nonces before the gateway calls the AI provider.

Request-bound authorization v2 is being rolled out in stages. The verifier, browser, and TypeScript SDK can already validate the v2 audience, method, encoded resource, content type, exact serialized body hash, and claimed payer; the public gateway continues issuing v1 contexts until the cutover is deployed.
Authorization v2 binds the payer and payment fields to the configured gateway audience, HTTP method, encoded path and raw query, content type, and SHA-256 hash of the exact request bytes. The verifier rejects malformed signatures, wrong chains, stale or future timestamps, replayed nonces, mismatched request bindings, and legacy authorization before the gateway calls the AI provider.

## Architecture

Expand Down Expand Up @@ -181,6 +179,7 @@ Signed retries include:
X-402-Signature: <wallet signature>
X-402-Nonce: <nonce from paymentContext>
X-402-Timestamp: <timestamp from paymentContext>
X-402-Payer: <wallet address covered by the signature>
```

Successful responses return the signed receipt as base64-encoded JSON in `X-402-Receipt`. See [`gateway/openapi.yaml`](gateway/openapi.yaml) for the complete contract.
Expand Down Expand Up @@ -231,7 +230,8 @@ The full local template is [`.env.example`](.env.example); production placeholde
| `RECIPIENT_ADDRESS`, `PAYMENT_AMOUNT` | Values embedded in payment contexts. |
| `CHAIN_ID`, `EXPECTED_CHAIN_ID` | Gateway and verifier chain IDs; these must match. |
| `VERIFIER_URL` | Verifier base URL used by the gateway; required at startup. |
| `PAYGATE_AUDIENCE` | Public gateway origin reserved for request-bound authorization v2; configure it before the gateway cutover. |
| `PAYGATE_AUDIENCE` | Required trusted public gateway origin used by request-bound authorization. Never derived from forwarded host headers. |
| `MIN_AUTHORIZATION_VERSION` | Verifier minimum accepted authorization version; defaults to `2`. Set `1` only for an explicit rollback window. |
| `VERIFIER_NONCE_STORE` | `memory` locally or `redis` for shared replay protection. |
| `RECEIPT_STORE` | `memory` locally or `redis` for restart-safe receipts. |
| `REDIS_URL` | Required by Redis nonce, receipt, or response-cache modes. |
Expand All @@ -251,7 +251,7 @@ Run the checks for every component you change:
| Web | `cd web && bun run lint && bun run typecheck && bun run test:unit && bun run build` |
| SDK | `cd sdk/typescript && bun run typecheck && bun run test` |
| Unit suite | `bun run test:unit` |
| E2E | `RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run test:e2e` — also requires `OPENROUTER_API_KEY` for the default provider |
| E2E | `RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run test:e2e` — starts a deterministic OpenRouter-compatible mock when `OPENROUTER_API_KEY` is unset; set the key to exercise the live provider |

> [!TIP]
> Do not replace `bun run test:e2e` with plain `bun test`; the E2E script builds and starts the gateway and verifier first.
Expand Down
1 change: 1 addition & 0 deletions deploy/fly/gateway.fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ primary_region = "bom"
[env]
PORT = "3000"
VERIFIER_URL = "http://<verifier-app>.internal:3002"
PAYGATE_AUDIENCE = "https://<gateway-app>.fly.dev"
OPENROUTER_MODEL = "z-ai/glm-4.5-air:free"
PAYMENT_AMOUNT = "0.001"
CHAIN_ID = "84532"
Expand Down
1 change: 1 addition & 0 deletions deploy/fly/verifier.fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ primary_region = "bom"

[env]
EXPECTED_CHAIN_ID = "84532"
MIN_AUTHORIZATION_VERSION = "2"
SIGNATURE_EXPIRY_SECONDS = "300"
SIGNATURE_CLOCK_SKEW_SECONDS = "60"
VERIFIER_NONCE_STORE = "redis"
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
environment:
- PORT=3000
- VERIFIER_URL=http://verifier:3002
- PAYGATE_AUDIENCE=${PAYGATE_AUDIENCE:-http://localhost:3000}
- REDIS_URL=redis:6379
- CACHE_ENABLED=${CACHE_ENABLED:-false}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3001}
Expand Down Expand Up @@ -50,6 +51,7 @@ services:
- VERIFIER_NONCE_STORE=${VERIFIER_NONCE_STORE:-redis}
- VERIFIER_NONCE_KEY_PREFIX=${VERIFIER_NONCE_KEY_PREFIX:-microai:verifier:nonce:}
- VERIFIER_REDIS_TIMEOUT_MS=${VERIFIER_REDIS_TIMEOUT_MS:-2000}
- MIN_AUTHORIZATION_VERSION=${MIN_AUTHORIZATION_VERSION:-2}
- REDIS_URL=${REDIS_URL:-redis:6379}
- REDIS_PASSWORD
- REDIS_DB
Expand Down
9 changes: 6 additions & 3 deletions gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The gateway is the public API entry point for MicroAI Paygate. It is a Go/Gin se
## Responsibilities

- Return `402 Payment Required` with a complete payment context when summarize requests are unsigned.
- Require signed retries to include `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp`.
- Require signed retries to include `X-402-Signature`, `X-402-Nonce`, `X-402-Timestamp`, and `X-402-Payer`.
- Reconstruct the payment context and call the Rust verifier at `POST /verify`.
- Map verifier business failures to sanitized public gateway errors.
- Call the configured AI provider only after payment verification succeeds.
Expand All @@ -25,7 +25,7 @@ sequenceDiagram

C->>G: POST /api/ai/summarize
G-->>C: 402 + paymentContext(recipient, token, amount, chainId, nonce, timestamp)
C->>G: Retry with X-402-Signature, X-402-Nonce, X-402-Timestamp
C->>G: Retry with X-402-Signature, nonce, timestamp, payer
Comment thread
AnkanMisra marked this conversation as resolved.
Outdated
G->>V: POST /verify with reconstructed context
V-->>G: valid + recovered wallet or structured error_code
alt Optional Redis response cache hit
Expand Down Expand Up @@ -94,7 +94,7 @@ Common optional variables:
| `OLLAMA_URL` | `http://localhost:11434` | Used when `AI_PROVIDER=ollama`. |
| `OLLAMA_MODEL` | `llama2` provider default | Used when `AI_PROVIDER=ollama`. |
| `VERIFIER_URL` | **required** (no fallback) | Where the gateway calls `/verify`. Use `http://127.0.0.1:3002` for `bun run stack`, `http://verifier:3002` in Compose, the platform's HTTPS URL in production. The gateway refuses to start if unset. |
| `PAYGATE_AUDIENCE` | unset | Public gateway origin reserved for request-bound authorization v2. Configure the production origin before the gateway cutover; the current v1 gateway does not read it. |
| `PAYGATE_AUDIENCE` | **required** | Trusted public gateway origin included in every v2 authorization. Origins only; paths, queries, fragments, credentials, and non-HTTP schemes are rejected at startup. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| `RECIPIENT_ADDRESS` | Development fallback address | Recipient embedded in payment contexts. |
| `PAYMENT_AMOUNT` | `0.001` | Amount string embedded in payment contexts. |
| `CHAIN_ID` | `84532` | Base Sepolia by default. Must match verifier `EXPECTED_CHAIN_ID`. |
Expand Down Expand Up @@ -165,6 +165,9 @@ The gateway exposes sanitized public errors and logs internal details with a `co
- `invalid_timestamp`
- `chain_id_mismatch`
- `nonce_already_used`
- `invalid_authorization_context`
- `authorization_version_too_old`
- `signer_mismatch`
- `verification_unavailable`
- `verifier_timeout`
- `upstream_unavailable`
Expand Down
2 changes: 1 addition & 1 deletion gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func CacheMiddleware() gin.HandlerFunc {
}
cacheHits.WithLabelValues(routePath).Inc()

payment, ok := verifyPaidRequest(c)
payment, ok := verifyPaidRequest(c, requestBody)
if !ok {
c.Abort()
return
Expand Down
3 changes: 2 additions & 1 deletion gateway/cache_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) {
isValid := req.Signature == "0xValidSig"
resp := VerifyResponse{
IsValid: isValid,
RecoveredAddress: "0xTestUser",
RecoveredAddress: "0x14791697260e4c9a71f18484c9f997b308e59325",
Error: "",
}
if !isValid {
Expand Down Expand Up @@ -116,6 +116,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-402-Signature", sig)
req.Header.Set("X-402-Payer", "0x14791697260E4c9A71f18484C9f997B308e59325")
req.Header.Set("X-402-Nonce", "nonce-123")
req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))

Expand Down
50 changes: 50 additions & 0 deletions gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package main
import (
"fmt"
"log"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -55,6 +57,54 @@ func isValidAllowedOrigin(origin string) bool {
return parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == ""
}

func normalizePaygateAudience() error {
raw := strings.TrimSpace(os.Getenv("PAYGATE_AUDIENCE"))
if raw == "" {
return fmt.Errorf("PAYGATE_AUDIENCE is required")
}

parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return fmt.Errorf("PAYGATE_AUDIENCE must be an absolute origin")
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
parsed.Scheme = strings.ToLower(parsed.Scheme)
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("PAYGATE_AUDIENCE scheme must be http or https")
}
if parsed.Hostname() == "" {
return fmt.Errorf("PAYGATE_AUDIENCE must have a non-empty host")
}
if parsed.User != nil {
return fmt.Errorf("PAYGATE_AUDIENCE must not contain credentials")
}
if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("PAYGATE_AUDIENCE must contain an origin only")
}

hostname := strings.ToLower(parsed.Hostname())
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
port := parsed.Port()
Comment thread
AnkanMisra marked this conversation as resolved.
if port != "" {
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 1 || portNumber > 65535 {
return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid port")
}
}
if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") {
port = ""
Comment thread
AnkanMisra marked this conversation as resolved.
}
canonicalHost := hostname
if port != "" {
canonicalHost = net.JoinHostPort(hostname, port)
} else if strings.Contains(hostname, ":") {
canonicalHost = "[" + hostname + "]"
}
canonical := parsed.Scheme + "://" + canonicalHost
if err := os.Setenv("PAYGATE_AUDIENCE", canonical); err != nil {
return fmt.Errorf("failed to normalize PAYGATE_AUDIENCE: %w", err)
}
return nil
}

func getReceiptStoreMode() string {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("RECEIPT_STORE")))
if mode == "" {
Expand Down
Loading
Loading