diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdd159a..4b7c0d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 -<<<<<<< HEAD - - name: Verify coverage thresholds - run: echo "Coverage thresholds 100% satisfied" -======= - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -124,4 +119,3 @@ jobs: uses: github/codeql-action/upload-sarif@v3 with: sarif_file: osv-results.sarif ->>>>>>> 7804e9c (feat(ci): generate CycloneDX SBOM and gate dependency vulnerabilities) diff --git a/.github/workflows/preview-smoke.yml b/.github/workflows/preview-smoke.yml new file mode 100644 index 0000000..691e18e --- /dev/null +++ b/.github/workflows/preview-smoke.yml @@ -0,0 +1,123 @@ +name: Preview smoke + +# Validates the things only a real deployment can prove — serverless routing, +# CORS, environment wiring, static assets, the SPA rewrite, and the shape of +# the x402 402 challenge — before a PR can merge. +# +# Trigger: Vercel's GitHub integration reports each Preview build through the +# `deployment_status` event, which carries the preview URL in +# `environment_url`. No Vercel token or project secret is needed here, and the +# suite itself is non-secret: it never signs or settles a payment, so a run +# costs 0 USDC. +# +# Required check: enable "Preview smoke tests" in branch protection for `main`. + +on: + deployment_status: + workflow_dispatch: + inputs: + preview_url: + description: 'Deployment URL to smoke-test (e.g. https://my-preview.vercel.app)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: preview-smoke-${{ github.event.deployment_status.target_url || github.event.inputs.preview_url || github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Preview smoke tests + runs-on: ubuntu-latest + # Only run once a Preview deployment has actually succeeded. Production + # deployments are skipped — this gate is about pre-merge validation. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.deployment_status.state == 'success' && + github.event.deployment_status.environment != 'Production' && + github.event.deployment_status.environment != 'production') + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + # No `npm ci`: scripts/smoke.mjs is dependency-free ESM on purpose, so + # this gate stays green even while the lockfile is being changed. + + - name: Resolve deployment URL + id: target + env: + DEPLOYMENT_URL: ${{ github.event.deployment_status.environment_url || github.event.deployment_status.target_url }} + DISPATCH_URL: ${{ github.event.inputs.preview_url }} + run: | + URL="${DISPATCH_URL:-$DEPLOYMENT_URL}" + if [ -z "$URL" ]; then + echo "::error::No deployment URL available on this event; nothing to smoke-test." + exit 1 + fi + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "Smoke-testing $URL" + + - name: Wait for the deployment to answer + env: + URL: ${{ steps.target.outputs.url }} + run: | + # A deployment can report "success" a moment before its edge routes + # are answering. Poll briefly so the suite fails on real breakage + # rather than on a cold start. + for attempt in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$URL/api/health" || true) + if [ "$code" != "000" ]; then + echo "Deployment answered with HTTP $code after ${attempt} attempt(s)." + exit 0 + fi + sleep 5 + done + echo "::error::$URL did not answer within 150s." + exit 1 + + - name: Run non-secret smoke suite + id: smoke + env: + URL: ${{ steps.target.outputs.url }} + run: | + node scripts/smoke.mjs "$URL" \ + --json smoke-artifacts/smoke-results.json \ + --markdown smoke-artifacts/smoke-report.md + + - name: Publish report to the job summary + if: always() + run: | + if [ -f smoke-artifacts/smoke-report.md ]; then + cat smoke-artifacts/smoke-report.md >> "$GITHUB_STEP_SUMMARY" + else + echo "No smoke report was produced — the suite could not run." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload response artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: preview-smoke-results + path: smoke-artifacts/ + if-no-files-found: warn + retention-days: 14 + + - name: Annotate each failed endpoint + # Surfaces the exact failing endpoint and status in the PR checks UI, + # so a reviewer does not have to open the artifact to see what broke. + if: failure() && hashFiles('smoke-artifacts/smoke-results.json') != '' + run: | + node -e ' + const s = require("./smoke-artifacts/smoke-results.json"); + for (const r of s.results.filter((x) => !x.ok)) { + const detail = r.failures.join(" | ").replace(/\r?\n/g, " "); + console.log(`::error title=${r.method} ${r.path} (${r.status ?? "no response"})::${detail}`); + } + ' diff --git a/README.md b/README.md index 1dab512..289a5fd 100644 --- a/README.md +++ b/README.md @@ -221,44 +221,381 @@ npm run reconcile:report --- +## Paid HTTP API — `/search`, `/images`, `/news` + +Three paid HTTP endpoints are exposed by the Express server. Each one costs +`PAYMENT_AMOUNT_USDC` (default **0.001 USDC**, `10000` stroops) settled on +Stellar through x402, and each one is guarded by the same middleware chain: + +``` +parameter validation (400) → x402 payment challenge (402) → replay guard (402) → Serper → 200 +``` + +Validation runs **first**, so a malformed request is refused before a payment +challenge is ever issued and before any payment payload is consumed. + +### Runtime availability + +| Endpoint | Express (`npm run server`) | Vercel (`api/`) | MCP tool | +|---|:--:|:--:|---| +| `GET /search` | ✅ | ✅ `GET /api/search` | `web_search` | +| `GET /images` | ✅ | ❌ *not deployed* | `image_search` | +| `GET /news` | ✅ | ❌ *not deployed* | `news_search` | + +> **Compatibility note:** `/images` and `/news` currently have **no Vercel +> serverless equivalent** — `api/` only implements `search`, `search/batch`, +> `jobs`, `jobs/[id]`, `ai/chat`, and `health`. Agents that need image or news +> search must target an Express deployment (or the MCP server, which proxies to +> one via `SEARCH_API_URL`). The MCP tools clamp `count` client-side before +> calling, so they never trip the 400s below. + +### Parameters and limits + +| Endpoint | `q` (required) | `count` | `freshness` | +|---|---|---|---| +| `GET /search` | 1–256 chars | integer `1..20`, default `5` | `pd` \| `pw` \| `pm` | +| `GET /images` | 1–256 chars | integer `1..10`, default `10` | **not supported** — ignored | +| `GET /news` | 1–256 chars | integer `1..20`, default `10` | `pd` \| `pw` \| `pm` | + +- **`q`** — required. Trimmed; ASCII control characters and null bytes are + stripped. Empty, missing, non-string, or longer than 256 characters → `400`. + Must be URL-encoded (use `curl --data-urlencode`, see below). +- **`count`** — optional. Must be a *single* integer inside the route's bounds. + Out-of-range (`0`, `-1`, `999`), non-integer (`abc`, `1.5`, `1e3`), and + repeated params (`?count=1&count=2`) are **rejected with `400`** — they are + *not* silently clamped. Forwarded to Serper as `num`. +- **`freshness`** — optional. Maps to the Serper `tbs` date filter: + `pd` → `qdr:d` (past day), `pw` → `qdr:w` (past week), `pm` → `qdr:m` (past + month). Any other value, or a repeated param, is **rejected with `400`**. + `/images` has no date filter, so `freshness` is accepted-and-ignored there + rather than rejected. +- **Rate limit** — `RATE_LIMIT_PER_MINUTE` (default `30`) per IP across all + routes; `429` with `Retry-After: 60` once exceeded. + +Bounds and enums live in `src/lib/paramValidation.ts` and are shared by every +paid route on both runtimes — see +[Parameter validation on paid endpoints (#188)](#parameter-validation-on-paid-endpoints-188). + +### Error responses + +| Status | Body | When | +|---|---|---| +| `400` | `{"error":"Missing required parameter: q"}` | `q` absent or blank | +| `400` | `{"error":"Query too long. Maximum 256 characters."}` | `q` over 256 chars | +| `400` | `{"error":"count must be between 1 and 10"}` | `/images?count=999` | +| `400` | `{"error":"count must be an integer"}` | `?count=1.5` | +| `400` | `{"error":"count must be a single value"}` | `?count=1&count=2` | +| `400` | `{"error":"freshness must be one of: pd, pw, pm"}` | `?freshness=yesterday` | +| `402` | `{}` + `PAYMENT-REQUIRED` header | no payment presented | +| `402` | `{"error":"Payment payload already consumed"}` | payment header replayed | +| `429` | `{"error":"Too many requests, please try again later."}` | rate limited | +| `502` | `{"error":"Serper.dev API error: "}` | upstream Serper failure | +| `500` | `{"error":"Image search failed. Check server logs."}` | unexpected server error | + +### Step 1 — the 402 challenge + +An unpaid request returns **`402` with an empty JSON body**. The challenge +itself travels in the base64-encoded **`PAYMENT-REQUIRED` response header** +(x402 v2), which is listed in `Access-Control-Expose-Headers` so browser +clients can read it cross-origin. + +```bash +curl -i --get \ + --data-urlencode 'q=stellar lumens' \ + http://localhost:3001/images +``` + +```http +HTTP/1.1 402 Payment Required +Content-Type: application/json; charset=utf-8 +Access-Control-Expose-Headers: PAYMENT-REQUIRED,X-Payment-Response +PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwi... + +{} +``` + +Decode it with: + +```bash +curl -sD - -o /dev/null --get \ + --data-urlencode 'q=stellar lumens' \ + http://localhost:3001/images \ + | grep -i '^payment-required:' | cut -d' ' -f2 \ + | tr -d '\r' | base64 -d | jq . +``` + +```jsonc +{ + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": "http://localhost:3001/images?q=stellar%20lumens", + "description": "StellarSearch: pay-per-query image search — 0.001 USDC on Stellar", + "mimeType": "" + }, + "accepts": [ + { + "scheme": "exact", + "network": "stellar:testnet", + "amount": "10000", // stroops, not dollars + "asset": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", // Soroban USDC contract + "payTo": "G...", // STELLAR_RECEIVING_ADDRESS + "maxTimeoutSeconds": 300, + "extra": { "areFeesSponsored": true } + } + ] +} +``` + +`/news` returns the identical structure with a `news search` description. +`asset` is always a Soroban **`C...` contract address**, never `USDC:ISSUER`. + +### Step 2 — pay and retry + +Sign the Soroban authorization entry from `accepts[0]` (Freighter in the +browser, `@x402/fetch` for agents) and replay the request with the signed +payload. The server accepts the payload on any of these request headers: + +| Header | Notes | +|---|---| +| `PAYMENT-SIGNATURE` | x402 **v2** — what `@x402/fetch` sends by default | +| `X-PAYMENT` | x402 **v1** compatibility | +| `Authorization` | accepted by the replay guard for legacy clients | + +The facilitator's settlement receipt comes back on the **`X-PAYMENT-RESPONSE`** +response header, and the server echoes it into the JSON body as `txHash`. + +Each payload is single-use: replaying one within its validity window returns +`402 {"error":"Payment payload already consumed"}` (see +[Payment Integrity & Replay Protection](#payment-integrity--replay-protection)). + +In practice you do not hand-roll this — use the x402 client: + +```bash +# Quote the challenge without settling, then run the full paid flow +npm run search:cli -- "stellar lumens" --mode quote --json +npm run search:cli -- "stellar lumens" --mode search +``` + +### Step 3 — the paid response + +#### `GET /images` + +```bash +curl -s --get \ + --data-urlencode 'q=stellar lumens' \ + --data-urlencode 'count=1' \ + -H "PAYMENT-SIGNATURE: $SIGNED_PAYLOAD" \ + http://localhost:3001/images | jq . +``` + +```json +{ + "query": "stellar lumens", + "results": [ + { + "id": "1", + "title": "Stellar Lumens logo", + "imageUrl": "https://cdn.example.com/xlm.png", + "thumbnailUrl": "https://cdn.example.com/xlm-thumb.png", + "sourceUrl": "https://example.com/xlm", + "source": "example.com", + "width": 1200, + "height": 630 + } + ], + "count": 1, + "network": "stellar:testnet", + "paidAmount": "0.001", + "currency": "USDC", + "txHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "latencyMs": 412 +} +``` + +#### `GET /news` + +```bash +curl -s --get \ + --data-urlencode 'q=stellar lumens' \ + --data-urlencode 'count=1' \ + --data-urlencode 'freshness=pw' \ + -H "PAYMENT-SIGNATURE: $SIGNED_PAYLOAD" \ + http://localhost:3001/news | jq . +``` + +```json +{ + "query": "stellar lumens", + "results": [ + { + "id": "1", + "title": "Stellar network upgrade ships", + "url": "https://news.example.com/a", + "snippet": "Protocol 23 went live...", + "source": "Example News", + "publishedAt": "2 hours ago", + "imageUrl": "https://news.example.com/a.jpg" + } + ], + "count": 1, + "network": "stellar:testnet", + "paidAmount": "0.001", + "currency": "USDC", + "txHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "latencyMs": 388 +} +``` + +### Result fields + +Envelope fields shared by `/search`, `/images`, and `/news` +(`SearchResponse` / `ImageSearchResponse` / `NewsSearchResponse` in +`src/types/index.ts`): + +| Field | Type | Description | +|---|---|---| +| `query` | `string` | The sanitized query actually sent upstream | +| `results` | `array` | Normalized rows — see the per-endpoint tables below | +| `count` | `number` | `results.length` **after** normalization, so it can be lower than the requested `count` | +| `network` | `string` | `stellar:testnet` or `stellar:mainnet` | +| `paidAmount` | `string` | USDC settled for this request, e.g. `"0.001"` | +| `currency` | `string` | Always `"USDC"` | +| `txHash` | `string \| null` | Settlement tx from `X-PAYMENT-RESPONSE`; `null` if the facilitator sent none | +| `latencyMs` | `number` | Upstream Serper round-trip, excluding payment settlement | + +`/search` additionally returns `originalQuery`, `executedQuery`, +`suggestedQuery`, `isCorrected`, and `suggestions` — see +[Spelling-Correction Metadata](#spelling-correction-metadata--user-confirmation-302). + +**`ImageResult`** — rows without a valid `http(s)` `imageUrl` are dropped by +`normalizeImageResults`: + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | 1-based index within this response | +| `title` | `string` | Image title, or `"No title"` | +| `imageUrl` | `string` | Full-resolution image URL (validated `http(s)`) | +| `thumbnailUrl` | `string` | Thumbnail URL; falls back to `imageUrl` | +| `sourceUrl` | `string` | Page hosting the image; falls back to `imageUrl` | +| `source` | `string` | Source domain | +| `width` / `height` | `number?` | Pixel dimensions when Serper reports them | + +**`NewsResult`** — rows without a valid `http(s)` `link` are dropped by +`normalizeNewsResults`: + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | 1-based index within this response | +| `title` | `string` | Headline, or `"No title"` | +| `url` | `string` | Article URL (validated `http(s)`) | +| `snippet` | `string` | Article excerpt; `""` when absent | +| `source` | `string` | Publication name; falls back to the URL hostname | +| `publishedAt` | `string?` | Relative age as reported by Serper, e.g. `"2 hours ago"` | +| `imageUrl` | `string?` | Article thumbnail when present | + +### Settlement guarantees + +Every paid `/search`, `/images`, and `/news` request appends a +`ReconciliationRecord` linking the payment identifier, the settlement tx hash, +and whether results were delivered — never the query text. See +[Settlement reconciliation](#settlement-reconciliation). + +--- + ## Project structure +Four runtimes share one set of contracts. Anything under `src/lib/` is +**shared** — imported by the browser bundle, the Express server, the Vercel +functions, and the MCP server alike — so a change there must keep all four +aligned. Everything else is runtime-specific. + ``` stellar-search/ -├── src/ # React frontend -│ ├── hooks/ -│ │ ├── useFreighterWallet.ts # Real Freighter + Horizon integration -│ │ └── useSearch.ts # Calls real server endpoint +├── src/ # SHARED types/logic + React frontend (browser) +│ ├── lib/ # ── shared by browser, Express, Vercel, MCP ── +│ │ ├── config.ts # Typed env parsing: server / browser / MCP views +│ │ ├── constants.ts # STELLAR_NETWORK, USDC_CONTRACT, AMOUNT_STROOPS +│ │ ├── paramValidation.ts # count/freshness contract for every paid route (#188) +│ │ ├── paymentIntegrity.ts # x402 payload identity + single-use replay guard +│ │ ├── reconciliation.ts # ReconciliationRecord builder + drift classification +│ │ ├── serverHealth.ts # /health stats contract: what each runtime measures (#226) +│ │ ├── serperNormalizer.ts # Serper → SearchResult / ImageResult / NewsResult +│ │ ├── receiptBundle.ts # Signed receipt bundle export + integrity proofs +│ │ ├── hashing.ts # Hash helpers for receipts/proofs +│ │ ├── aiChatService.ts # Groq chat client (browser-side) +│ │ ├── onboarding.ts # First-run onboarding state (#342) +│ │ └── stellar.ts # Horizon/explorer helpers (browser-facing) +│ ├── types/index.ts # SHARED response, batch JSONL, and job contracts │ ├── components/ -│ │ ├── AnimatedBackground.tsx # Canvas animation -│ │ ├── WalletPanel.tsx # Real Freighter connect + live balances -│ │ ├── PaymentFlowVisualizer.tsx -│ │ ├── SearchResults.tsx -│ │ ├── StatsGrid.tsx # Polls real /health endpoint -│ │ └── GroqAssistant.tsx # Real Groq AI chat -│ ├── pages/ -│ │ ├── SearchPage.tsx -│ │ ├── DocsPage.tsx -│ │ └── DashboardPage.tsx # Live Horizon tx history -│ └── lib/stellar.ts # Horizon helpers -├── server/ -│ └── index.ts # Express + @x402/express + Serper.dev + Groq + batch/jsonl + jobs/webhooks -├── api/ -│ ├── search.ts # Vercel parity for GET /search -│ ├── search/batch.ts # Vercel parity for POST /search/batch (JSONL) -│ ├── jobs.ts # POST /jobs (+ GET /jobs list) -│ ├── jobs/[id].ts # GET /jobs/:id status + verified payment -│ ├── ai/chat.ts # Vercel AI chat (streaming) -│ └── health.ts # Vercel health -├── mcp-server/ -│ └── index.ts # MCP tools + resources + prompts + progress +│ │ ├── search/ # SearchBar, SearchResults, ImageResults, NewsResults, +│ │ │ # ModeSelector, SearchSuggestions, SavedResearchPanel, +│ │ │ # SpellingCorrectionBanner, PaymentFlowVisualizer +│ │ ├── wallet/WalletPanel.tsx # Freighter connect + live Horizon balances +│ │ ├── layout/ # Navbar, Footer, AnimatedBackground, LiveTicker +│ │ ├── ai/GroqAssistant.tsx # Groq AI chat panel +│ │ ├── onboarding/ # OnboardingFlow (#342) +│ │ └── ui/ # StatsGrid (health-contract aware), ZeroBalanceBanner +│ ├── hooks/ # useSearch, useFreighterWallet, useSavedResearch, +│ │ # usePageVisible +│ ├── pages/ # SearchPage, DocsPage, DashboardPage +│ └── i18n/ # i18next setup + locales/en/*.json (#345) +│ +├── server/ # EXPRESS runtime (the only one serving /images, /news) +│ ├── index.ts # App + x402 middleware, paid-route param validation, +│ │ # /search /images /news /search/batch /jobs /health /ai/chat +│ ├── corsConfig.ts # Allow-list CORS options shared by all Express routes +│ ├── logger.ts # Winston JSON logger +│ └── reconciliationStore.ts # Append-only JSONL settlement log +│ +├── api/ # VERCEL serverless runtime (no /images or /news — see +│ │ # "Runtime availability" under Paid HTTP API) +│ ├── index.ts # Service descriptor for GET /api +│ ├── search.ts # Vercel parity for GET /search +│ ├── search/batch.ts # Vercel parity for POST /search/batch (JSONL) +│ ├── jobs.ts # POST /jobs + GET /jobs list +│ ├── jobs/[id].ts # GET /jobs/:id status + verified payment +│ ├── ai/chat.ts # Groq AI chat (free) +│ └── health.ts # Health: config facts + declared stats gap (#226) +│ +├── mcp-server/index.ts # MCP runtime: web_search, image_search, news_search, +│ # ai_summarize, check_balance, get_search_stats +│ # + resources, prompts, progress notifications +│ ├── scripts/ -│ └── test-search.ts # End-to-end test script -├── .env.example -├── claude_mcp.json +│ ├── test-search.ts # CLI: discovery / quote / search modes (+ receipts) +│ ├── check-config.ts # `npm run config:check` env validation +│ ├── reconcile-report.ts # `npm run reconcile:report` settlement drift report +│ ├── smoke.mjs # Non-secret preview smoke suite (dependency-free) +│ ├── verify-sbom.mjs # CI: assert the CycloneDX SBOM is well-formed +│ ├── check-vulnerabilities.mjs # CI: HIGH/CRITICAL dependency gate +│ └── setup.sh # `npm run setup` +│ +├── .github/workflows/ +│ ├── ci.yml # Typecheck, lint, test, coverage gate, supply chain +│ └── preview-smoke.yml # Smoke-tests the Vercel Preview URL on every PR +│ +├── vercel.json # Serverless routing, SPA rewrites, CORS headers +├── vite.config.ts # Vite build, dev proxy → Express, coverage thresholds +├── tsconfig*.json # Per-runtime TS projects: base / node / server / api / +│ # mcp / scripts +├── .env.example # Placeholders only — never real secrets +├── claude_mcp.json # Claude Desktop / Claude Code MCP registration └── README.md ``` +Tests live beside the code they cover as `*.test.ts` / `*.test.tsx`, so each +runtime's suite stays with that runtime: + +| Scope | Notable suites | +|---|---| +| Shared (`src/lib/`) | `paramValidation`, `paymentIntegrity`, `serperNormalizer`, `reconciliation`, `receiptBundle`, `serverHealth`, `config`, `constants`, `hashing`, `onboarding`, `stellar` | +| Express (`server/`) | `parameterMatrix` (all paid routes), `payment`, `validateQuery`, `corsConfig`, `health`, `reconciliationStore`, `reconciliation.integration` | +| Vercel (`api/`) | `search`, `search/batch`, `jobs`, `jobs/[id]`, `ai/chat`, `health` | +| MCP (`mcp-server/`) | `tools`, `clampCount` | +| Browser (`src/`) | `SearchPage`, `DocsPage`, `SearchBar`, `SpellingCorrectionBanner`, `ZeroBalanceBanner`, `StatsGrid`, `useSearch`, `useFreighterWallet`, `usePageVisible`, `i18n` | +| Scripts | `scripts/test-search.test.ts`, `scripts/smoke.test.ts` | + --- ## Internationalization (#345) @@ -450,9 +787,11 @@ Global thresholds are deliberately modest initially and ratchet upward as paymen | `src/lib/paymentIntegrity.ts` | 90% | 85% | 95% | 90% | | `src/lib/serperNormalizer.ts` | 95% | 90% | 100% | 95% | | `src/lib/paramValidation.ts` | 95% | 90% | 100% | 95% | +| `src/lib/serverHealth.ts` | 95% | 90% | 100% | 95% | | `server/corsConfig.ts` | 90% | 85% | 95% | 90% | | `src/components/search/SearchBar.tsx` | 80% | 80% | 90% | 80% | | `src/components/search/SpellingCorrectionBanner.tsx` | 85% | 90% | 70% | 85% | +| `src/components/ui/StatsGrid.tsx` | 90% | 90% | 100% | 95% | | `src/pages/SearchPage.tsx` | 65% | 65% | 70% | 75% | | `server/index.ts` | 30% | 24% | 25% | 35% | | `api/search.ts` | 90% | 75% | 80% | 90% | @@ -470,6 +809,246 @@ Coverage verifies the **x402 settlement semantics** for paid routes (`/search`, --- +## Health and statistics (`/health`) + +`/health` reports two different kinds of thing, and they must not be confused: + +- **Configuration facts** — network, price, facilitator, which keys are set. + Every runtime knows these and reports them. +- **Activity statistics** — `totalQueries`, `totalUsdcSettled`, `avgLatencyMs`, + `uptime`. Only a runtime that actually measures them can report them. + +Express keeps those counters in the same process that serves the paid routes, +so it measures all four. A Vercel function cannot: it is stateless, scales to +zero, and each request may land on a fresh instance, so an in-memory counter +there would describe one warm instance rather than the deployment. + +Previously the serverless handler simply omitted the four fields, the browser +coalesced them (`data.totalQueries ?? 0`), and a Vercel deployment rendered +**"0 queries · $0.00 settled · 0ms"** beside a live green **SERVER ONLINE** +pulse. Those were not measurements — they were missing data presented as fact. +The MCP `get_search_stats` tool had it worse: `stats.totalQueries.toLocaleString()` +threw a `TypeError` on the absent field and surfaced as a misleading +`Failed to fetch server stats`. + +### Every runtime declares what it measures + +`src/lib/serverHealth.ts` holds the shared contract. Each `/health` response +now carries a declaration: + +| Field | Type | Meaning | +|---|---|---| +| `statsSupported` | `boolean` | Whether this runtime measures the activity statistics | +| `unsupportedFields` | `string[]` | Which of `totalQueries`, `totalUsdcSettled`, `avgLatencyMs`, `uptime` it does not measure — empty when `statsSupported` is `true` | +| `statsUnavailableReason` | `string?` | Human-readable explanation; present only when something is unsupported | + +**Express** (`GET /health`) — measures everything: + +```jsonc +{ + "status": "ok", + "network": "stellar:testnet", + "pricePerQuery": "0.001 USDC", + "protocol": "x402", + "totalQueries": 12, + "totalUsdcSettled": "0.0120", + "avgLatencyMs": 384, + "uptime": "7m", + "statsSupported": true, + "unsupportedFields": [] +} +``` + +**Vercel** (`GET /api/health`) — configuration only, gap declared: + +```jsonc +{ + "status": "ok", + "network": "stellar:testnet", + "pricePerQuery": "0.001 USDC", + "protocol": "x402", + "timestamp": "2026-09-02T12:00:00.000Z", + "statsSupported": false, + "unsupportedFields": ["totalQueries", "totalUsdcSettled", "avgLatencyMs", "uptime"], + "statsUnavailableReason": "Serverless functions are stateless and scale to zero, so per-instance counters would reset on every cold start instead of reporting deployment activity. Run the Express server (npm run server) for live counters." +} +``` + +The counters are **omitted, not zeroed**. A field declared unsupported must +carry no value at all — the preview smoke suite fails a deployment that leaves +a stale one behind. + +### Reading the statistics + +Consumers call `resolveStat(health, field)` instead of reading the field +directly. It returns either `{ available: true, value }` or +`{ available: false, reason }`, which keeps the three states apart: + +| State | `resolveStat` | UI | +|---|---|---| +| Measured, non-zero | `{ available: true, value: 1234 }` | `1,234` with the live pulse | +| **Measured, genuinely zero** | `{ available: true, value: 0 }` | `0` with the live pulse | +| **Not measured** | `{ available: false, reason }` | `n/a`, dimmed, no pulse, reason on hover | +| Server unreachable | `{ available: false, reason }` | `n/a`, `SERVER OFFLINE` | + +The second and third rows are the distinction that matters: a freshly started +Express server that has served no queries **really has** served no queries, and +that is worth showing. A serverless deployment that never counted anything is +not the same claim. + +`resolveStat` also tolerates a deployment predating this contract: present +values are trusted, absent ones are reported as undeclared rather than assumed +to be zero. An explicit declaration always wins over a stale value in the +payload. + +### Behavior per consumer + +- **Browser** (`src/components/ui/StatsGrid.tsx`) — unmeasured cards render + `n/a`, dimmed, without the pulsing "live" dot, with the reason as a `title` + and as screen-reader text. One panel-level note explains the whole grid when + nothing is measured. The server still reads **ONLINE**, because it is. +- **MCP** (`get_search_stats`) — unmeasured values print as `not reported` + followed by a single `⚠️` line with the reason, instead of throwing. +- **Preview smoke** (`scripts/smoke.mjs`) — the `/api/health` check fails a + deployment that omits the counters without declaring them, that declares + support but omits the values, or that declares a field unsupported while + still reporting it. + +### Migration notes + +- **Additive and backward-compatible.** No existing field changed type or + meaning; `statsSupported`, `unsupportedFields`, and `statsUnavailableReason` + are new. Older clients that read `totalQueries` directly still work against + Express and see the same behavior as before against Vercel. +- **If you add a runtime**, spread `declareStatsSupported()` or + `declareStatsUnsupported(reason)` into its `/health` body. The smoke suite + fails an undeclared deployment, so the gap cannot ship silently. +- **If you add durable serverless counters** (Vercel KV, Redis, or similar), + switch `api/health.ts` to `declareStatsSupported()` and report the real + values. Nothing downstream needs to change — the UI and MCP already render + whatever is declared available. +- **No effect on paid routes.** This contract covers reporting only; the x402 + settlement path and its verified semantics are untouched. + +--- + +## Preview deployment smoke tests + +Unit tests cannot see a deployment. Serverless routing, CORS, environment +wiring, static assets, and the SPA rewrite only exist once Vercel has built a +Preview — so every pull request runs a smoke suite against its own Preview URL +before it can merge. + +- **Suite:** `scripts/smoke.mjs` (12 checks) +- **Workflow:** `.github/workflows/preview-smoke.yml` — job name **`Preview smoke tests`** +- **Routing config under test:** `vercel.json` + +### What it checks + +| # | Check | Endpoint | Expected | Catches | +|---|---|---|---|---| +| 1 | SPA shell | `GET /` | `200` `text/html` with `#root` | broken build output / `outputDirectory` | +| 2 | Static asset | `GET /favicon.svg` | `200` `image/svg+xml` | assets not published | +| 3 | SPA rewrite | `GET /docs` | `200` `text/html` | missing `rewrites` in `vercel.json` | +| 4 | Service descriptor | `GET /api` | `200` JSON, `name: StellarSearch` | serverless routing not wired | +| 5 | Environment wiring + stats declaration | `GET /api/health` | `200`, `status: ok`, `protocol: x402`, and a valid `statsSupported` declaration | missing `STELLAR_RECEIVING_ADDRESS` / `SERPER_API_KEY`; counters omitted without being declared (see [Health and statistics](#health-and-statistics-health)) | +| 6 | CORS preflight | `OPTIONS /api/search` | `200`/`204` allowing `payment-signature` + `x-payment` | browser clients unable to send the signed payload | +| 7 | Method guard | `POST /api/search` | `405` | handler-level regressions | +| 8 | Missing `q` | `GET /api/search` | `400` | validation not reached | +| 9 | `count` out of bounds | `GET /api/search?count=999` | `400` **not** `402` | validation running after the payment gate | +| 10 | Repeated `count` | `?count=1&count=2` | `400` | array coercion regressions | +| 11 | Unknown `freshness` | `?freshness=yesterday` | `400` | enum drift | +| 12 | **x402 challenge** | `GET /api/search?q=…` | `402` + valid `PAYMENT-REQUIRED` | settlement-semantics drift (see below) | + +Check 12 decodes the base64 `PAYMENT-REQUIRED` header and asserts the +**verified x402 settlement semantics** that a bad deploy silently breaks: +`scheme=exact`, `network=stellar:testnet|stellar:mainnet`, an **integer stroop** +`amount` (never a decimal dollar figure), an `asset` that is a Soroban `C…` +contract address (never `USDC:ISSUER`), a `payTo` Stellar `G…` address, and a +positive `maxTimeoutSeconds`. It also asserts `PAYMENT-REQUIRED` is listed in +`Access-Control-Expose-Headers`, without which a browser client cannot read the +challenge at all. + +### Non-secret by construction + +The suite needs **no repository secrets, no Vercel token, no wallet, and no +signing material**, and it **never settles a payment** — the x402 checks stop at +the 402 challenge, so a run costs **0 USDC**. It is also dependency-free plain +ESM, so CI runs it straight from a checkout without `npm ci`. + +Response artifacts are header-filtered before they are written: `authorization`, +`set-cookie`, `x-api-key`, and the payment headers are never captured, and +bodies are truncated to 2 KB. + +### Failure reporting + +A failing run fails the check and reports the **exact endpoint** three ways: + +1. a `::error::` annotation per failed endpoint in the PR checks UI, titled + `METHOD /path (status)`; +2. a Markdown report in the job summary naming each failed endpoint, the status + received versus expected, and a collapsible **response artifact** (filtered + headers + truncated body); +3. an uploaded `preview-smoke-results` artifact (14-day retention) containing + `smoke-results.json` and `smoke-report.md`. + +``` +✗ 2 of 12 checks failed: + GET https://preview.vercel.app/docs → 404 (expected 200) + deep link was not rewritten to index.html — got "text/plain". Check the "rewrites" block in vercel.json. + GET https://preview.vercel.app/api/health → 500 (expected 200) + health did not return JSON — a 500 here usually means required env vars + (STELLAR_RECEIVING_ADDRESS, SERPER_API_KEY) are not set on this deployment +``` + +### Running it yourself + +```bash +# Against any deployment (a bare host is assumed to be https) +node scripts/smoke.mjs https://your-preview.vercel.app + +# Capture artifacts locally +node scripts/smoke.mjs my-preview.vercel.app \ + --json smoke-artifacts/smoke-results.json \ + --markdown smoke-artifacts/smoke-report.md +``` + +Exit code is `0` when all checks pass and `1` otherwise. + +### Wiring it up + +1. Connect the repository to Vercel so Preview deployments are created for pull + requests. The workflow triggers on GitHub's `deployment_status` event, which + Vercel's integration emits with the Preview URL — **no Vercel token needed**. +2. In **Settings → Branches → Branch protection rules** for `main`, add + **`Preview smoke tests`** to the required status checks. +3. Set `STELLAR_RECEIVING_ADDRESS` and `SERPER_API_KEY` for the **Preview** + environment in Vercel Project Settings, or check 5 will fail by design. + +To smoke-test a URL by hand, run the workflow from **Actions → Preview smoke → +Run workflow** and paste the deployment URL. + +### `vercel.json` + +The routing config the suite validates: + +- `rewrites` — everything except `/api/*` falls through to `/index.html` so the + SPA renders on a deep link. +- `headers` — CORS for `/api/*`, allowing the `payment-signature` / `x-payment` + request headers and **exposing** `PAYMENT-REQUIRED`, `PAYMENT-RESPONSE`, and + `X-Payment-Response` so browser clients can complete the x402 flow; + `Cache-Control: no-store` so a paid response is never cached. +- `functions` — `maxDuration: 30` for `api/**/*.ts`, enough for the JSONL batch + stream and async job routes. + +> **Runtime boundary:** the Preview only exercises the **Vercel** runtime, which +> serves `/api/search`, `/api/search/batch`, `/api/jobs`, `/api/jobs/:id`, +> `/api/ai/chat`, and `/api/health`. `/images` and `/news` are **Express-only** +> and are covered by `server/parameterMatrix.test.ts` instead — see +> [Runtime availability](#runtime-availability). + +--- + ## Supply chain security & SBOM The `supply-chain` CI job generates a **CycloneDX SBOM** from the committed lockfile and runs a **dependency vulnerability gate** using [OSV-Scanner](https://google.github.io/osv-scanner/). diff --git a/api/health.test.ts b/api/health.test.ts index 770d6fa..70aca0e 100644 --- a/api/health.test.ts +++ b/api/health.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import handler from './health' +import { + MEASURED_STAT_FIELDS, + SERVERLESS_STATS_UNAVAILABLE_REASON, + resolveStat, + hasAnyStats, +} from '../src/lib/serverHealth' function mockRes() { const res: any = {} @@ -65,4 +71,59 @@ describe('api/health — Vercel health aligned with server /health', () => { const payload = res.json.mock.calls[0][0] expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/) }) + // ─── Statistics declaration (#226) ──────────────────────────────────────── + // A Vercel function is stateless and scales to zero, so it cannot hold a + // durable counter. Rather than omit the fields silently — which let the UI + // render the absence as "0 queries, $0.00 settled" — it declares the gap. + + it('declares that it measures no activity statistics, with a reason', async () => { + const req: any = { method: 'GET', headers: {} } + const res = mockRes() + await handler(req, res) + const payload = res.json.mock.calls[0][0] + + expect(payload.statsSupported).toBe(false) + expect(payload.unsupportedFields).toEqual([...MEASURED_STAT_FIELDS]) + expect(payload.statsUnavailableReason).toBe(SERVERLESS_STATS_UNAVAILABLE_REASON) + }) + + it('omits the counters entirely rather than reporting a fabricated zero', async () => { + const req: any = { method: 'GET', headers: {} } + const res = mockRes() + await handler(req, res) + const payload = res.json.mock.calls[0][0] + + for (const field of MEASURED_STAT_FIELDS) { + expect(payload[field]).toBeUndefined() + } + }) + + it('resolves every statistic as unavailable through the shared contract', async () => { + const req: any = { method: 'GET', headers: {} } + const res = mockRes() + await handler(req, res) + const payload = res.json.mock.calls[0][0] + + expect(hasAnyStats(payload)).toBe(false) + for (const field of MEASURED_STAT_FIELDS) { + expect(resolveStat(payload, field)).toEqual({ + available: false, + reason: SERVERLESS_STATS_UNAVAILABLE_REASON, + }) + } + }) + + it('still reports the configuration facts it genuinely knows', async () => { + const req: any = { method: 'GET', headers: {} } + const res = mockRes() + await handler(req, res) + const payload = res.json.mock.calls[0][0] + + // Declaring the counters unavailable must not weaken the settlement facts. + expect(payload.status).toBe('ok') + expect(payload.protocol).toBe('x402') + expect(payload.pricePerQuery).toBe('0.001 USDC') + expect(payload.network).toMatch(/^stellar:(testnet|mainnet)$/) + expect(payload.receivingAddressConfigured).toBe(true) + }) }) diff --git a/api/health.ts b/api/health.ts index 3c1f23c..9895ea3 100644 --- a/api/health.ts +++ b/api/health.ts @@ -1,10 +1,21 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' import { readServerConfig } from '../src/lib/config' +import { + declareStatsUnsupported, + SERVERLESS_STATS_UNAVAILABLE_REASON, + type ServerHealthResponse, +} from '../src/lib/serverHealth' export default function handler(req: VercelRequest, res: VercelResponse) { const config = readServerConfig() - res.json({ + // This runtime reports configuration facts only. It deliberately does NOT + // report totalQueries / totalUsdcSettled / avgLatencyMs / uptime: a Vercel + // function is stateless and scales to zero, so an in-memory counter would + // describe one warm instance rather than the deployment. Declaring the gap + // (#226) stops the UI and the MCP stats tool from rendering the absence as + // a real zero. See src/lib/serverHealth.ts. + const body: ServerHealthResponse = { status: 'ok', network: config.stellarNetwork, pricePerQuery: `${config.amountUsdc} USDC`, @@ -14,5 +25,8 @@ export default function handler(req: VercelRequest, res: VercelResponse) { groqApiConfigured: !!config.groqApiKey, receivingAddressConfigured: true, timestamp: new Date().toISOString(), - }) + ...declareStatsUnsupported(SERVERLESS_STATS_UNAVAILABLE_REASON), + } + + res.json(body) } diff --git a/api/search.ts b/api/search.ts index 1647b6e..f7b7d8b 100644 --- a/api/search.ts +++ b/api/search.ts @@ -4,6 +4,12 @@ import { USDC_CONTRACT_TESTNET, } from '../src/lib/constants' import { consumePaymentPayload } from '../src/lib/paymentIntegrity' +import { + validateCount, + validateFreshness, + FRESHNESS_TBS, + SEARCH_COUNT, +} from '../src/lib/paramValidation' import { formatConfigurationError, readServerConfig } from '../src/lib/config' // ─── Config ─────────────────────────────────────────────────────────────── @@ -45,13 +51,30 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(405).json(errorBody) } - const { q, count = '5', freshness } = req.query as Record + const { q } = req.query as Record if (!q?.trim()) { const errorBody: ApiErrorResponse = { error: 'Missing required parameter: q' } return res.status(400).json(errorBody) } + // ─── Parameter validation (#188) ───────────────────────────────────────── + // Runs BEFORE the 402 challenge and the replay check, matching Express: + // a request the server would refuse anyway never reaches the payment + // adapter, so the caller is neither charged nor handed a payment challenge. + const validatedCount = validateCount(req.query.count, SEARCH_COUNT) + if (!validatedCount.ok) { + const errorBody: ApiErrorResponse = { error: validatedCount.error } + return res.status(400).json(errorBody) + } + const validatedFreshness = validateFreshness(req.query.freshness) + if (!validatedFreshness.ok) { + const errorBody: ApiErrorResponse = { error: validatedFreshness.error } + return res.status(400).json(errorBody) + } + const count = validatedCount.value + const tbs = validatedFreshness.value ? FRESHNESS_TBS[validatedFreshness.value] : undefined + // ─── Payment check ──────────────────────────────────────────────────────── const paymentHeader = req.headers['payment-signature'] || @@ -115,17 +138,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { // ─── Serper.dev ────────────────────────────────────────────────────────── const requestBody: Record = { q: q.trim(), - num: Math.min(parseInt(count) || 5, 20), - } - - if (freshness) { - const dateFilters: Record = { - pd: 'qdr:d', // past day - pw: 'qdr:w', // past week - pm: 'qdr:m', // past month - } - if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] + num: count, } + if (tbs) requestBody.tbs = tbs const serperRes = await fetch('https://google.serper.dev/search', { method: 'POST', diff --git a/mcp-server/index.ts b/mcp-server/index.ts index 3b7f457..d1ee5cd 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -41,6 +41,7 @@ import { AMOUNT_STROOPS, } from '../src/lib/constants' import { formatConfigurationError, readMcpConfig } from '../src/lib/config' +import { resolveStat, statsUnavailableReason } from '../src/lib/serverHealth' dotenv.config() @@ -295,7 +296,7 @@ Use for breaking stories, current events, and time-sensitive reporting.`, }, { name: 'get_search_stats', - description: 'Get live statistics from the StellarSearch server (total queries, USDC settled, uptime, latencies).', + description: 'Get live statistics from the StellarSearch server (total queries, USDC settled, uptime, latencies). Counters come from the Express server; a stateless serverless deployment reports configuration only and these fields come back as "not reported" rather than zero.', inputSchema: { type: 'object', properties: {}, @@ -752,23 +753,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const stats = (await res.json()) as any + // Statistics are read through the shared health contract (#226) rather + // than off the payload: a serverless deployment declares that it does not + // measure them, so they must render as "not reported" instead of a zero — + // and reading `stats.totalQueries.toLocaleString()` directly would throw + // there, surfacing as a bogus "failed to fetch stats". + const uptime = resolveStat(stats, 'uptime') + const totalQueries = resolveStat(stats, 'totalQueries') + const totalUsdcSettled = resolveStat(stats, 'totalUsdcSettled') + const avgLatencyMs = resolveStat(stats, 'avgLatencyMs') + const unavailable = statsUnavailableReason(stats) + + const lines = [ + `📊 StellarSearch Server Stats`, + ` Status: ${String(stats.status ?? 'unknown').toUpperCase()}`, + ` Network: ${stats.network}`, + ` Uptime: ${uptime.available ? uptime.value : 'not reported'}`, + ` Total Queries: ${totalQueries.available ? Number(totalQueries.value).toLocaleString() : 'not reported'}`, + ` USDC Settled: ${totalUsdcSettled.available ? `${totalUsdcSettled.value} USDC` : 'not reported'}`, + ` Avg Latency: ${avgLatencyMs.available ? `${avgLatencyMs.value}ms` : 'not reported'}`, + ` Price per Query: ${stats.pricePerQuery}`, + ` Facilitator: ${stats.facilitator}`, + ` APIs Configured: Serper: ${stats.serperApiConfigured ? '✅' : '❌'}, Groq: ${stats.groqApiConfigured ? '✅' : '❌'}`, + ] + if (unavailable) { + lines.push('', `⚠️ Activity counters are not available on this deployment. ${unavailable}`) + } + cleanup() return { content: [ { type: 'text', - text: [ - `📊 StellarSearch Server Stats`, - ` Status: ${stats.status.toUpperCase()}`, - ` Network: ${stats.network}`, - ` Uptime: ${stats.uptime}`, - ` Total Queries: ${stats.totalQueries.toLocaleString()}`, - ` USDC Settled: ${stats.totalUsdcSettled} USDC`, - ` Avg Latency: ${stats.avgLatencyMs}ms`, - ` Price per Query: ${stats.pricePerQuery}`, - ` Facilitator: ${stats.facilitator}`, - ` APIs Configured: Serper: ${stats.serperApiConfigured ? '✅' : '❌'}, Groq: ${stats.groqApiConfigured ? '✅' : '❌'}`, - ].join('\n'), + text: lines.join('\n'), }, ], } diff --git a/mcp-server/tools.test.ts b/mcp-server/tools.test.ts index 3701f61..4c77797 100644 --- a/mcp-server/tools.test.ts +++ b/mcp-server/tools.test.ts @@ -42,6 +42,11 @@ process.env.GROQ_API_KEY = 'gsk_test' process.env.SEARCH_API_URL = 'http://localhost:3001' import { HORIZON_URL, USDC_ISSUER, STELLAR_NETWORK, AMOUNT_USDC } from '../src/lib/constants' +import { + SERVERLESS_STATS_UNAVAILABLE_REASON, + declareStatsSupported, + declareStatsUnsupported, +} from '../src/lib/serverHealth' describe('MCP server — alignment with Express/Vercel/browser constants', () => { it('MCP uses same AMOUNT_USDC as server (x402 settlement)', async () => { @@ -106,3 +111,104 @@ describe('MCP server — alignment with Express/Vercel/browser constants', () => } }) }) + +// ─── get_search_stats and the health statistics contract (#226) ───────────── + +describe('MCP get_search_stats — honours the health statistics declaration', () => { + const HEALTH_CONFIG = { + status: 'ok', + network: 'stellar:testnet', + pricePerQuery: '0.001 USDC', + protocol: 'x402', + facilitator: 'https://www.x402.org/facilitator', + serperApiConfigured: true, + groqApiConfigured: true, + receivingAddressConfigured: true, + } + + /** Invokes get_search_stats against a stubbed /health payload. */ + async function callStats(health: Record): Promise { + await import('./index.js') + const callToolCall = mockSetRequestHandler.mock.calls.find((c) => c[0] === CallToolRequestSchemaMock) + const callToolHandler = callToolCall?.[1] as Function + expect(callToolHandler).toBeDefined() + + const originalFetch = global.fetch + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => health }) as any + try { + const result: any = await callToolHandler({ params: { name: 'get_search_stats', arguments: {} } }) + expect(result.isError).toBeFalsy() + return result.content[0].text as string + } finally { + global.fetch = originalFetch + } + } + + it('prints live counters from an Express deployment', async () => { + const text = await callStats({ + ...HEALTH_CONFIG, + totalQueries: 1234, + totalUsdcSettled: '1.2340', + avgLatencyMs: 412, + uptime: '7m', + ...declareStatsSupported(), + }) + + expect(text).toContain('Total Queries: 1,234') + expect(text).toContain('USDC Settled: 1.2340 USDC') + expect(text).toContain('Avg Latency: 412ms') + expect(text).toContain('Uptime: 7m') + expect(text).not.toContain('not reported') + }) + + it('prints a genuine zero as zero rather than "not reported"', async () => { + const text = await callStats({ + ...HEALTH_CONFIG, + totalQueries: 0, + totalUsdcSettled: '0.0000', + avgLatencyMs: 0, + uptime: '3s', + ...declareStatsSupported(), + }) + + expect(text).toContain('Total Queries: 0') + expect(text).toContain('Avg Latency: 0ms') + expect(text).not.toContain('not reported') + }) + + it('reports unmeasured counters as "not reported" against a serverless deployment', async () => { + // Before #226 this path threw on `undefined.toLocaleString()` and surfaced + // as a bogus "Failed to fetch server stats". + const text = await callStats({ + ...HEALTH_CONFIG, + ...declareStatsUnsupported(SERVERLESS_STATS_UNAVAILABLE_REASON), + }) + + expect(text).toContain('Total Queries: not reported') + expect(text).toContain('USDC Settled: not reported') + expect(text).toContain('Avg Latency: not reported') + expect(text).toContain('Uptime: not reported') + // No fabricated zeros anywhere in the rendered stats. + expect(text).not.toMatch(/Total Queries:\s+0/) + expect(text).not.toMatch(/Avg Latency:\s+0ms/) + }) + + it('explains once why the counters are missing, without hiding the config facts', async () => { + const text = await callStats({ + ...HEALTH_CONFIG, + ...declareStatsUnsupported(SERVERLESS_STATS_UNAVAILABLE_REASON), + }) + + expect(text).toContain('Activity counters are not available on this deployment') + expect(text).toContain(SERVERLESS_STATS_UNAVAILABLE_REASON) + // Settlement configuration is still reported truthfully. + expect(text).toContain('Price per Query: 0.001 USDC') + expect(text).toContain('Network: stellar:testnet') + }) + + it('does not throw on a pre-contract payload that omits both counters and declaration', async () => { + const text = await callStats({ ...HEALTH_CONFIG }) + expect(text).toContain('Total Queries: not reported') + expect(text).toContain('Activity counters are not available on this deployment') + }) +}) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..6695bfb --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,634 @@ +#!/usr/bin/env node +/** + * scripts/smoke.mjs + * + * Non-secret smoke suite for a deployed StellarSearch preview (issue: "Run + * smoke tests against preview deployments before merge"). + * + * Validates what only a real deployment can prove — serverless routing, CORS + * wiring, environment wiring, static assets, and the SPA rewrite — plus the + * x402 payment semantics that must survive every deploy. + * + * NON-SECRET BY CONSTRUCTION: + * - no API keys, no wallet, no signing material, no repository secrets; + * - it never settles a payment, so a run costs 0 USDC. The x402 checks stop + * at the 402 challenge and assert its *shape* (scheme/network/amount/ + * asset/payTo), which is exactly the part a bad deploy breaks. + * + * Zero dependencies: plain ESM on Node 18+ (global `fetch`), so CI can run it + * straight from a checkout without `npm ci`. + * + * Usage: + * node scripts/smoke.mjs [--json ] [--markdown ] + * + * Exit code 0 when every check passes, 1 otherwise. Failures name the exact + * endpoint and carry the captured response artifact. + */ + +/** Response headers worth keeping in the artifact. Anything else is dropped. */ +const ARTIFACT_HEADERS = [ + 'content-type', + 'cache-control', + 'payment-required', + 'payment-response', + 'x-payment-response', + 'access-control-allow-origin', + 'access-control-allow-methods', + 'access-control-allow-headers', + 'access-control-expose-headers', + 'x-vercel-id', + 'x-vercel-cache', + 'x-matched-path', + 'server', +] + +/** Header names that must never reach an artifact, however the server replies. */ +const REDACTED_HEADERS = ['authorization', 'set-cookie', 'x-api-key', 'payment-signature', 'x-payment'] + +const MAX_BODY_SNIPPET = 2000 + +const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/ +const SOROBAN_CONTRACT = /^C[A-Z2-7]{55}$/ + +/** The query every check reuses. Encoded via URLSearchParams, never by hand. */ +const SAMPLE_QUERY = 'stellar lumens' + +/** + * Decodes the base64 `PAYMENT-REQUIRED` challenge header into the x402 v2 + * payment-requirements object. + * + * @param {string} value Raw header value. + * @returns {{ok: true, value: object} | {ok: false, error: string}} + */ +export function decodePaymentRequired(value) { + if (typeof value !== 'string' || value.trim() === '') { + return { ok: false, error: 'PAYMENT-REQUIRED header is missing or empty' } + } + let json + try { + json = Buffer.from(value, 'base64').toString('utf8') + } catch { + return { ok: false, error: 'PAYMENT-REQUIRED header is not valid base64' } + } + try { + const parsed = JSON.parse(json) + if (!parsed || typeof parsed !== 'object') { + return { ok: false, error: 'PAYMENT-REQUIRED header did not decode to an object' } + } + return { ok: true, value: parsed } + } catch { + return { ok: false, error: `PAYMENT-REQUIRED header is not valid JSON: ${json.slice(0, 120)}` } + } +} + +/** + * Asserts the x402 settlement semantics carried by a 402 challenge. + * + * These are the invariants a broken deploy silently violates: the wrong + * network, dollars instead of stroops, or `USDC:ISSUER` instead of the Soroban + * contract address. Returns a list of human-readable failures (empty = pass). + * + * @param {object} challenge Decoded payment-requirements object. + * @returns {string[]} Failure descriptions. + */ +export function checkPaymentRequirements(challenge) { + const failures = [] + if (challenge.x402Version !== 2) { + failures.push(`expected x402Version 2, got ${JSON.stringify(challenge.x402Version)}`) + } + const accepts = challenge.accepts + if (!Array.isArray(accepts) || accepts.length === 0) { + failures.push('challenge has no `accepts` payment options') + return failures + } + const option = accepts[0] + if (option.scheme !== 'exact') { + failures.push(`accepts[0].scheme must be "exact", got ${JSON.stringify(option.scheme)}`) + } + if (!/^stellar:(testnet|mainnet)$/.test(String(option.network))) { + failures.push(`accepts[0].network must be stellar:testnet|stellar:mainnet, got ${JSON.stringify(option.network)}`) + } + // Amounts are stroops (integer strings), never a decimal dollar figure. + if (!/^\d+$/.test(String(option.amount))) { + failures.push(`accepts[0].amount must be an integer stroop string, got ${JSON.stringify(option.amount)}`) + } + if (!SOROBAN_CONTRACT.test(String(option.asset))) { + failures.push(`accepts[0].asset must be a Soroban C... contract address (not "USDC:ISSUER"), got ${JSON.stringify(option.asset)}`) + } + if (!STELLAR_ADDRESS.test(String(option.payTo))) { + failures.push(`accepts[0].payTo must be a Stellar G... address, got ${JSON.stringify(option.payTo)}`) + } + if (typeof option.maxTimeoutSeconds !== 'number' || option.maxTimeoutSeconds <= 0) { + failures.push(`accepts[0].maxTimeoutSeconds must be a positive number, got ${JSON.stringify(option.maxTimeoutSeconds)}`) + } + return failures +} + +/** The activity statistics a `/health` response may report (see src/lib/serverHealth.ts). */ +const MEASURED_STAT_FIELDS = ['totalQueries', 'totalUsdcSettled', 'avgLatencyMs', 'uptime'] + +/** + * Asserts that a health payload says what it measures. + * + * A serverless deployment cannot hold a durable counter, so it must declare the + * gap rather than omit the fields silently — a silent omission is what let the + * UI render "0 queries, $0.00 settled" as if it were a live measurement. Either + * declaration is acceptable here; an undeclared omission is not. + * + * @param {object} health Parsed `/health` body. + * @returns {string[]} Failure descriptions. + */ +export function checkStatsDeclaration(health) { + const failures = [] + if (typeof health.statsSupported !== 'boolean') { + failures.push( + `health must declare \`statsSupported\` (true/false) so consumers can tell an unmeasured field from a real zero, got ${JSON.stringify(health.statsSupported)}`, + ) + return failures + } + if (!Array.isArray(health.unsupportedFields)) { + failures.push(`health must declare \`unsupportedFields\` as an array, got ${JSON.stringify(health.unsupportedFields)}`) + return failures + } + + if (health.statsSupported) { + if (health.unsupportedFields.length > 0) { + failures.push(`statsSupported is true but unsupportedFields is not empty: ${JSON.stringify(health.unsupportedFields)}`) + } + // A runtime claiming to measure must actually report the values. + for (const field of MEASURED_STAT_FIELDS) { + if (health[field] === undefined || health[field] === null) { + failures.push(`statsSupported is true but \`${field}\` is missing from the response`) + } + } + return failures + } + + // Unsupported: the gap must be explained, and no fabricated value left behind. + if (typeof health.statsUnavailableReason !== 'string' || health.statsUnavailableReason.trim() === '') { + failures.push('statsSupported is false but no statsUnavailableReason was given') + } + for (const field of health.unsupportedFields) { + if (health[field] !== undefined) { + failures.push(`\`${field}\` is declared unsupported but a value was still reported: ${JSON.stringify(health[field])}`) + } + } + return failures +} + +/** Builds a query string without hand-encoding anything. */ +function qs(params) { + const sp = new URLSearchParams() + for (const [k, v] of params) sp.append(k, v) + return `?${sp.toString()}` +} + +/** + * The smoke checks. Each `expect` receives the captured response and returns a + * list of failures; an empty list means the check passed. + * + * `status` is asserted separately so a wrong status reports cleanly even when + * the body is unparseable (an HTML error page from a misrouted request, say). + */ +export const CHECKS = [ + { + id: 'static-spa-shell', + name: 'Static SPA shell is served at /', + method: 'GET', + path: '/', + status: [200], + expect: ({ headers, text }) => { + const failures = [] + if (!String(headers['content-type'] || '').includes('text/html')) { + failures.push(`expected text/html, got ${JSON.stringify(headers['content-type'])}`) + } + if (!text.includes('id="root"')) failures.push('index.html did not contain the #root mount point') + return failures + }, + }, + { + id: 'static-favicon', + name: 'Static asset /favicon.svg is served', + method: 'GET', + path: '/favicon.svg', + status: [200], + expect: ({ headers }) => + String(headers['content-type'] || '').includes('svg') + ? [] + : [`expected an SVG content-type, got ${JSON.stringify(headers['content-type'])}`], + }, + { + id: 'spa-rewrite-deep-link', + name: 'SPA rewrite serves the shell for a non-API deep link', + method: 'GET', + path: '/docs', + status: [200], + expect: ({ headers, text }) => { + const failures = [] + if (!String(headers['content-type'] || '').includes('text/html')) { + failures.push( + `deep link was not rewritten to index.html — got ${JSON.stringify(headers['content-type'])}. Check the "rewrites" block in vercel.json.`, + ) + } + if (!text.includes('id="root"')) failures.push('rewritten response was not the SPA shell') + return failures + }, + }, + { + id: 'api-service-descriptor', + name: 'GET /api returns the service descriptor', + method: 'GET', + path: '/api', + status: [200], + expect: ({ json }) => { + if (!json) return ['response body was not JSON — serverless routing for api/index.ts may be broken'] + const failures = [] + if (json.name !== 'StellarSearch') failures.push(`expected name "StellarSearch", got ${JSON.stringify(json.name)}`) + if (!json.endpoints || typeof json.endpoints !== 'object') failures.push('descriptor is missing the `endpoints` map') + return failures + }, + }, + { + id: 'api-health-env-wiring', + name: 'GET /api/health proves environment wiring', + method: 'GET', + path: '/api/health', + status: [200], + expect: ({ json }) => { + if (!json) { + return [ + 'health did not return JSON — a 500 here usually means required env vars (STELLAR_RECEIVING_ADDRESS, SERPER_API_KEY) are not set on this deployment', + ] + } + const failures = [] + if (json.status !== 'ok') failures.push(`expected status "ok", got ${JSON.stringify(json.status)}`) + if (json.protocol !== 'x402') failures.push(`expected protocol "x402", got ${JSON.stringify(json.protocol)}`) + if (!/^stellar:(testnet|mainnet)$/.test(String(json.network))) { + failures.push(`network must be stellar:testnet|stellar:mainnet, got ${JSON.stringify(json.network)}`) + } + if (json.receivingAddressConfigured !== true) failures.push('receivingAddressConfigured is not true') + failures.push(...checkStatsDeclaration(json)) + return failures + }, + }, + { + id: 'cors-preflight', + name: 'CORS preflight on /api/search advertises the x402 payment headers', + method: 'OPTIONS', + path: '/api/search', + headers: { + Origin: 'https://example.com', + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'payment-signature', + }, + status: [200, 204], + expect: ({ headers }) => { + const failures = [] + if (!headers['access-control-allow-origin']) failures.push('missing Access-Control-Allow-Origin') + const methods = String(headers['access-control-allow-methods'] || '').toUpperCase() + if (!methods.includes('GET')) failures.push(`Access-Control-Allow-Methods must include GET, got ${JSON.stringify(headers['access-control-allow-methods'])}`) + const allowed = String(headers['access-control-allow-headers'] || '').toLowerCase() + // Without these a browser client can never send the signed payload. + for (const required of ['payment-signature', 'x-payment']) { + if (!allowed.includes(required)) { + failures.push(`Access-Control-Allow-Headers must include ${required}, got ${JSON.stringify(headers['access-control-allow-headers'])}`) + } + } + return failures + }, + }, + { + id: 'api-search-method-guard', + name: 'POST /api/search is rejected with 405', + method: 'POST', + path: '/api/search', + status: [405], + expect: ({ json }) => (json && json.error ? [] : ['expected a JSON error body']), + }, + { + id: 'api-search-missing-q', + name: 'GET /api/search without `q` is rejected with 400', + method: 'GET', + path: '/api/search', + status: [400], + expect: ({ json }) => + json && /Missing required parameter: q/.test(String(json.error)) + ? [] + : [`expected "Missing required parameter: q", got ${JSON.stringify(json && json.error)}`], + }, + { + id: 'api-search-count-out-of-bounds', + name: 'GET /api/search?count=999 is rejected with 400 before any payment challenge', + method: 'GET', + path: () => `/api/search${qs([['q', SAMPLE_QUERY], ['count', '999']])}`, + status: [400], + expect: ({ json }) => + json && /count/.test(String(json.error)) + ? [] + : [`expected a count validation error (not a 402 challenge), got ${JSON.stringify(json && json.error)}`], + }, + { + id: 'api-search-count-repeated', + name: 'GET /api/search with a repeated `count` is rejected with 400', + method: 'GET', + path: () => `/api/search${qs([['q', SAMPLE_QUERY], ['count', '1'], ['count', '2']])}`, + status: [400], + expect: ({ json }) => + json && /single value/.test(String(json.error)) + ? [] + : [`expected a "single value" error, got ${JSON.stringify(json && json.error)}`], + }, + { + id: 'api-search-bad-freshness', + name: 'GET /api/search with an unknown `freshness` is rejected with 400', + method: 'GET', + path: () => `/api/search${qs([['q', SAMPLE_QUERY], ['freshness', 'yesterday']])}`, + status: [400], + expect: ({ json }) => + json && /freshness/.test(String(json.error)) + ? [] + : [`expected a freshness validation error, got ${JSON.stringify(json && json.error)}`], + }, + { + id: 'api-search-x402-challenge', + name: 'GET /api/search returns a well-formed x402 402 challenge', + method: 'GET', + path: () => `/api/search${qs([['q', SAMPLE_QUERY], ['count', '3']])}`, + status: [402], + expect: ({ headers }) => { + const decoded = decodePaymentRequired(headers['payment-required']) + if (!decoded.ok) return [decoded.error] + const failures = checkPaymentRequirements(decoded.value) + const exposed = String(headers['access-control-expose-headers'] || '').toLowerCase() + if (!exposed.includes('payment-required')) { + failures.push( + `Access-Control-Expose-Headers must include PAYMENT-REQUIRED or browser clients cannot read the challenge, got ${JSON.stringify(headers['access-control-expose-headers'])}`, + ) + } + return failures + }, + }, +] + +/** Copies only the allow-listed headers, dropping anything sensitive. */ +function captureHeaders(res) { + const out = {} + for (const name of ARTIFACT_HEADERS) { + if (REDACTED_HEADERS.includes(name)) continue + const value = res.headers.get(name) + if (value !== null && value !== undefined) out[name] = value + } + return out +} + +/** + * Runs a single check against `baseUrl`. + * + * Never throws: a transport failure becomes a failed result so the report can + * name the endpoint that could not be reached. + * + * @param {object} check One entry from `CHECKS`. + * @param {string} baseUrl Deployment origin, without a trailing slash. + * @param {typeof fetch} fetchImpl Injected for tests. + * @returns {Promise} The check result. + */ +export async function runCheck(check, baseUrl, fetchImpl = fetch) { + const path = typeof check.path === 'function' ? check.path() : check.path + const url = `${baseUrl}${path}` + const startedAt = Date.now() + + let res + try { + res = await fetchImpl(url, { + method: check.method, + headers: { 'User-Agent': 'stellar-search-smoke/1', ...(check.headers || {}) }, + redirect: 'manual', + }) + } catch (err) { + return { + id: check.id, + name: check.name, + method: check.method, + path, + url, + ok: false, + status: null, + expectedStatus: check.status, + durationMs: Date.now() - startedAt, + headers: {}, + bodySnippet: '', + failures: [`request failed: ${err && err.message ? err.message : String(err)}`], + } + } + + const text = await res.text().catch(() => '') + let json = null + try { + json = JSON.parse(text) + } catch { + // Non-JSON is expected for the static/SPA checks. + } + + const headers = captureHeaders(res) + const failures = [] + if (!check.status.includes(res.status)) { + failures.push(`expected HTTP ${check.status.join(' or ')}, got ${res.status}`) + } + // Body assertions still run on a wrong status — they usually explain why. + failures.push(...check.expect({ status: res.status, headers, text, json })) + + return { + id: check.id, + name: check.name, + method: check.method, + path, + url, + ok: failures.length === 0, + status: res.status, + expectedStatus: check.status, + durationMs: Date.now() - startedAt, + headers, + bodySnippet: text.length > MAX_BODY_SNIPPET ? `${text.slice(0, MAX_BODY_SNIPPET)}… [truncated]` : text, + failures, + } +} + +/** + * Runs the whole suite sequentially. + * + * @param {string} rawBaseUrl Deployment URL (trailing slash tolerated). + * @param {{fetchImpl?: typeof fetch, checks?: object[]}} [options] + * @returns {Promise<{ok: boolean, baseUrl: string, startedAt: string, results: object[], passed: number, failed: number}>} + */ +export async function runSmoke(rawBaseUrl, options = {}) { + const { fetchImpl = fetch, checks = CHECKS } = options + const baseUrl = normalizeBaseUrl(rawBaseUrl) + const startedAt = new Date().toISOString() + + const results = [] + for (const check of checks) { + results.push(await runCheck(check, baseUrl, fetchImpl)) + } + + const failed = results.filter((r) => !r.ok).length + return { + ok: failed === 0, + baseUrl, + startedAt, + results, + passed: results.length - failed, + failed, + } +} + +/** + * Validates and normalizes the deployment URL. + * + * @param {string} raw Candidate URL; a bare host is assumed to be https. + * @returns {string} Origin with no trailing slash. + */ +export function normalizeBaseUrl(raw) { + if (typeof raw !== 'string' || raw.trim() === '') { + throw new Error('A deployment URL is required, e.g. node scripts/smoke.mjs https://my-preview.vercel.app') + } + const trimmed = raw.trim() + // Only a scheme-less value gets the https default; anything that already + // names a scheme is passed through so a bad one is reported, not smuggled + // into the path of an https URL. + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) + const candidate = hasScheme ? trimmed : `https://${trimmed}` + let parsed + try { + parsed = new URL(candidate) + } catch { + throw new Error(`Invalid deployment URL: ${raw}`) + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Deployment URL must be http(s), got ${parsed.protocol}`) + } + return `${parsed.origin}${parsed.pathname.replace(/\/$/, '')}` +} + +/** + * Renders a GitHub-friendly Markdown report. Failing checks come first and + * carry the captured response so the summary alone identifies the breakage. + * + * @param {object} summary Result of `runSmoke`. + * @returns {string} Markdown. + */ +export function formatMarkdown(summary) { + const lines = [] + lines.push(`## Preview smoke ${summary.ok ? '✅ passed' : '❌ failed'}`) + lines.push('') + lines.push(`**Deployment:** ${summary.baseUrl}`) + lines.push('') + lines.push(`**Result:** ${summary.passed} passed, ${summary.failed} failed, ${summary.results.length} total`) + lines.push('') + + const failures = summary.results.filter((r) => !r.ok) + if (failures.length > 0) { + lines.push('### Failed endpoints') + lines.push('') + for (const r of failures) { + lines.push(`#### \`${r.method} ${r.path}\` — ${r.name}`) + lines.push('') + lines.push(`- **URL:** ${r.url}`) + lines.push(`- **Status:** ${r.status === null ? 'no response' : r.status} (expected ${r.expectedStatus.join(' or ')})`) + for (const f of r.failures) lines.push(`- ${f}`) + lines.push('') + lines.push('
Response artifact') + lines.push('') + lines.push('```json') + lines.push(JSON.stringify({ headers: r.headers, body: r.bodySnippet }, null, 2)) + lines.push('```') + lines.push('') + lines.push('
') + lines.push('') + } + } + + lines.push('### All checks') + lines.push('') + lines.push('| | Check | Endpoint | Status | ms |') + lines.push('|---|---|---|---:|---:|') + for (const r of summary.results) { + lines.push( + `| ${r.ok ? '✅' : '❌'} | ${r.name} | \`${r.method} ${r.path}\` | ${r.status === null ? '—' : r.status} | ${r.durationMs} |`, + ) + } + lines.push('') + return lines.join('\n') +} + +/** Parses `--json ` / `--markdown ` out of argv. */ +export function parseArgs(argv) { + const positional = [] + const options = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--json' || arg === '--markdown') { + const value = argv[i + 1] + if (!value || value.startsWith('--')) throw new Error(`${arg} requires a file path`) + options[arg.slice(2)] = value + i++ + } else if (arg.startsWith('--')) { + throw new Error(`Unknown option: ${arg}`) + } else { + positional.push(arg) + } + } + return { baseUrl: positional[0], options } +} + +/* c8 ignore start — CLI wiring, exercised by CI rather than unit tests */ +async function main() { + const { writeFileSync, mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + let baseUrl + let options + try { + ;({ baseUrl, options } = parseArgs(process.argv.slice(2))) + baseUrl = normalizeBaseUrl(baseUrl) + } catch (err) { + console.error(`✗ ${err.message}`) + process.exit(1) + } + + console.log(`Running ${CHECKS.length} non-secret smoke checks against ${baseUrl}\n`) + const summary = await runSmoke(baseUrl) + + for (const r of summary.results) { + console.log(`${r.ok ? '✓' : '✗'} ${r.method} ${r.path} — ${r.name} (${r.status ?? 'no response'}, ${r.durationMs}ms)`) + for (const f of r.failures) console.log(` → ${f}`) + } + + const write = (path, contents) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + console.log(`\nWrote ${path}`) + } + if (options.json) write(options.json, `${JSON.stringify(summary, null, 2)}\n`) + if (options.markdown) write(options.markdown, formatMarkdown(summary)) + + if (!summary.ok) { + const failed = summary.results.filter((r) => !r.ok) + console.error(`\n✗ ${summary.failed} of ${summary.results.length} checks failed:`) + for (const r of failed) { + console.error(` ${r.method} ${r.url} → ${r.status ?? 'no response'} (expected ${r.expectedStatus.join(' or ')})`) + for (const f of r.failures) console.error(` ${f}`) + if (r.bodySnippet) console.error(` body: ${r.bodySnippet.slice(0, 400)}`) + } + process.exit(1) + } + + console.log(`\n✓ All ${summary.results.length} checks passed against ${baseUrl}`) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} +/* c8 ignore stop */ diff --git a/scripts/smoke.test.ts b/scripts/smoke.test.ts new file mode 100644 index 0000000..2df2611 --- /dev/null +++ b/scripts/smoke.test.ts @@ -0,0 +1,635 @@ +/** + * scripts/smoke.test.ts + * + * Covers the preview-deployment smoke suite (`scripts/smoke.mjs`) with a + * stubbed deployment, so the gate that guards every preview is itself tested: + * + * - primary flow — a healthy deployment passes all checks; + * - boundary — the x402 challenge validator accepts only real + * settlement semantics (stroops, Soroban C... asset, G... + * payTo) and rejects the near-misses a bad deploy produces; + * - failure paths — wrong status, missing SPA rewrite, missing CORS payment + * headers, unreachable host, and malformed input, each + * reported with the exact endpoint and a response artifact. + */ + +import { describe, it, expect, vi } from 'vitest' +// @ts-expect-error — plain ESM helper, intentionally dependency-free for CI. +import { + CHECKS, + runCheck, + runSmoke, + decodePaymentRequired, + checkPaymentRequirements, + checkStatsDeclaration, + normalizeBaseUrl, + formatMarkdown, + parseArgs, +} from './smoke.mjs' + +const BASE = 'https://stellar-search-preview.vercel.app' + +const PAY_TO = 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3' +const USDC_CONTRACT = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA' + +const SPA_SHELL = '
' + +function challenge(overrides: Record = {}) { + const payload = { + x402Version: 2, + error: 'Payment required', + resource: { url: `${BASE}/api/search`, description: 'StellarSearch', mimeType: 'application/json' }, + accepts: [ + { + scheme: 'exact', + network: 'stellar:testnet', + amount: '10000', + asset: USDC_CONTRACT, + payTo: PAY_TO, + maxTimeoutSeconds: 300, + extra: { areFeesSponsored: true }, + ...overrides, + }, + ], + } + return Buffer.from(JSON.stringify(payload)).toString('base64') +} + +function response(body: string, init: { status?: number; headers?: Record } = {}) { + const headers = new Map(Object.entries(init.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])) + return { + status: init.status ?? 200, + headers: { get: (name: string) => headers.get(name.toLowerCase()) ?? null }, + text: async () => body, + } +} + +/** A stub of a correctly deployed preview. `overrides` break one route at a time. */ +function deployment(overrides: Record> = {}) { + return vi.fn(async (url: string) => { + const { pathname, searchParams } = new URL(url) + const key = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}` + if (overrides[key]) return overrides[key] + if (overrides[pathname]) return overrides[pathname] + + if (pathname === '/' || pathname === '/docs') { + return response(SPA_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } }) + } + if (pathname === '/favicon.svg') { + return response('', { headers: { 'content-type': 'image/svg+xml' } }) + } + if (pathname === '/api') { + return response(JSON.stringify({ name: 'StellarSearch', version: '1.0.0', endpoints: {} }), { + headers: { 'content-type': 'application/json' }, + }) + } + if (pathname === '/api/health') { + return response( + JSON.stringify({ + status: 'ok', + network: 'stellar:testnet', + protocol: 'x402', + receivingAddressConfigured: true, + serperApiConfigured: true, + // Serverless declares that it measures no activity counters (#226). + statsSupported: false, + unsupportedFields: ['totalQueries', 'totalUsdcSettled', 'avgLatencyMs', 'uptime'], + statsUnavailableReason: 'Serverless functions are stateless.', + }), + { headers: { 'content-type': 'application/json' } }, + ) + } + if (pathname === '/api/search') { + // Parameter validation precedes the payment challenge (#188). + const counts = searchParams.getAll('count') + const freshness = searchParams.getAll('freshness') + const json = (status: number, error: string) => + response(JSON.stringify({ error }), { status, headers: { 'content-type': 'application/json' } }) + + if (!searchParams.get('q')) return json(400, 'Missing required parameter: q') + if (counts.length > 1) return json(400, 'count must be a single value') + if (counts.length === 1 && Number(counts[0]) > 20) return json(400, 'count must be between 1 and 20') + if (freshness.length === 1 && !['pd', 'pw', 'pm'].includes(freshness[0])) { + return json(400, 'freshness must be one of: pd, pw, pm') + } + return response('{}', { + status: 402, + headers: { + 'content-type': 'application/json', + 'payment-required': challenge(), + 'access-control-expose-headers': 'PAYMENT-REQUIRED, X-Payment-Response', + }, + }) + } + return response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } }) + }) +} + +/** POST and OPTIONS are keyed by method rather than path in the stub above. */ +function deploymentWithMethods(overrides: Record> = {}) { + const base = deployment(overrides) + return vi.fn(async (url: string, init: any) => { + const { pathname } = new URL(url) + if (init?.method === 'OPTIONS' && pathname === '/api/search') { + return ( + overrides['OPTIONS /api/search'] ?? + response('', { + status: 204, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': 'Content-Type, Authorization, X-Payment, payment-signature', + }, + }) + ) + } + if (init?.method === 'POST' && pathname === '/api/search') { + return ( + overrides['POST /api/search'] ?? + response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'content-type': 'application/json' }, + }) + ) + } + return base(url) + }) +} + +// ─── Primary flow ──────────────────────────────────────────────────────────── + +describe('smoke suite — primary flow', () => { + it('passes every check against a correctly wired deployment', async () => { + const summary = await runSmoke(BASE, { fetchImpl: deploymentWithMethods() }) + + expect(summary.ok).toBe(true) + expect(summary.failed).toBe(0) + expect(summary.passed).toBe(CHECKS.length) + expect(summary.results.every((r: any) => r.failures.length === 0)).toBe(true) + }) + + it('covers serverless routing, CORS, env wiring, static assets, and x402 in one run', async () => { + const summary = await runSmoke(BASE, { fetchImpl: deploymentWithMethods() }) + const ids = summary.results.map((r: any) => r.id) + + expect(ids).toEqual( + expect.arrayContaining([ + 'static-spa-shell', + 'static-favicon', + 'spa-rewrite-deep-link', + 'api-service-descriptor', + 'api-health-env-wiring', + 'cors-preflight', + 'api-search-x402-challenge', + ]), + ) + }) + + it('never sends a payment payload, so a run settles nothing', async () => { + const fetchImpl = deploymentWithMethods() + await runSmoke(BASE, { fetchImpl }) + + for (const [, init] of fetchImpl.mock.calls) { + const sent = Object.keys((init as any)?.headers ?? {}).map((h) => h.toLowerCase()) + expect(sent).not.toContain('payment-signature') + expect(sent).not.toContain('x-payment') + expect(sent).not.toContain('authorization') + } + }) + + it('encodes queries rather than interpolating them raw', async () => { + const fetchImpl = deploymentWithMethods() + await runSmoke(BASE, { fetchImpl }) + + const searchCall = fetchImpl.mock.calls.map(([u]) => String(u)).find((u) => u.includes('count=3')) + expect(searchCall).toContain('q=stellar+lumens') + expect(searchCall).not.toContain('q=stellar lumens') + }) +}) + +// ─── Boundary: x402 settlement semantics ───────────────────────────────────── + +describe('checkPaymentRequirements — x402 settlement semantics', () => { + const valid = { + x402Version: 2, + accepts: [ + { scheme: 'exact', network: 'stellar:testnet', amount: '10000', asset: USDC_CONTRACT, payTo: PAY_TO, maxTimeoutSeconds: 300 }, + ], + } + + it('accepts a well-formed testnet challenge', () => { + expect(checkPaymentRequirements(valid)).toEqual([]) + }) + + it('accepts mainnet as well as testnet', () => { + const mainnet = { ...valid, accepts: [{ ...valid.accepts[0], network: 'stellar:mainnet' }] } + expect(checkPaymentRequirements(mainnet)).toEqual([]) + }) + + it('rejects a decimal dollar amount where stroops are required', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], amount: '0.001' }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/integer stroop string/)]) + }) + + it('rejects the classic "USDC:ISSUER" asset regression', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], asset: `USDC:${PAY_TO}` }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/Soroban C\.\.\. contract address/)]) + }) + + it('rejects a G... address used as the asset', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], asset: PAY_TO }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/Soroban C\.\.\./)]) + }) + + it('rejects a non-Stellar payTo', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], payTo: '0xdeadbeef' }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/payTo must be a Stellar G\.\.\./)]) + }) + + it('rejects an unexpected network', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], network: 'base-sepolia' }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/network must be stellar:/)]) + }) + + it('rejects a non-exact scheme', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], scheme: 'upto' }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/scheme must be "exact"/)]) + }) + + it('rejects a v1 challenge and an empty accepts list', () => { + expect(checkPaymentRequirements({ ...valid, x402Version: 1 })).toEqual([expect.stringMatching(/x402Version 2/)]) + expect(checkPaymentRequirements({ x402Version: 2, accepts: [] })).toEqual([ + expect.stringMatching(/no `accepts` payment options/), + ]) + }) + + it('rejects a missing or non-positive maxTimeoutSeconds', () => { + const bad = { ...valid, accepts: [{ ...valid.accepts[0], maxTimeoutSeconds: 0 }] } + expect(checkPaymentRequirements(bad)).toEqual([expect.stringMatching(/maxTimeoutSeconds/)]) + }) +}) + +describe('decodePaymentRequired', () => { + it('decodes a base64 challenge header', () => { + const decoded = decodePaymentRequired(challenge()) + expect(decoded.ok).toBe(true) + expect(decoded.value.x402Version).toBe(2) + }) + + it('reports a missing header instead of throwing', () => { + expect(decodePaymentRequired(undefined)).toEqual({ ok: false, error: expect.stringMatching(/missing or empty/) }) + expect(decodePaymentRequired('')).toEqual({ ok: false, error: expect.stringMatching(/missing or empty/) }) + }) + + it('reports a header that is not JSON', () => { + const notJson = Buffer.from('definitely not json').toString('base64') + expect(decodePaymentRequired(notJson)).toEqual({ ok: false, error: expect.stringMatching(/not valid JSON/) }) + }) + + it('reports a header that decodes to a bare scalar', () => { + const scalar = Buffer.from('42').toString('base64') + expect(decodePaymentRequired(scalar)).toEqual({ ok: false, error: expect.stringMatching(/did not decode to an object/) }) + }) +}) + +// ─── Failure paths ─────────────────────────────────────────────────────────── + +describe('smoke suite — failure paths', () => { + it('fails and names the endpoint when the SPA rewrite is missing', async () => { + const fetchImpl = deploymentWithMethods({ + '/docs': response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + expect(summary.ok).toBe(false) + const failed = summary.results.find((r: any) => !r.ok) + expect(failed.id).toBe('spa-rewrite-deep-link') + expect(failed.path).toBe('/docs') + expect(failed.url).toBe(`${BASE}/docs`) + expect(failed.failures.join(' ')).toMatch(/expected HTTP 200, got 404/) + expect(failed.failures.join(' ')).toMatch(/vercel\.json/) + }) + + it('fails when CORS does not advertise the x402 payment headers', async () => { + const fetchImpl = deploymentWithMethods({ + 'OPTIONS /api/search': response('', { + status: 204, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': 'Content-Type', + }, + }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'cors-preflight') + expect(failed.ok).toBe(false) + expect(failed.failures.join(' ')).toMatch(/payment-signature/) + expect(failed.failures.join(' ')).toMatch(/x-payment/) + }) + + it('fails with a config hint when /api/health 500s on missing env vars', async () => { + const fetchImpl = deploymentWithMethods({ + '/api/health': response('A server error has occurred', { status: 500, headers: { 'content-type': 'text/plain' } }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-health-env-wiring') + expect(failed.ok).toBe(false) + expect(failed.status).toBe(500) + expect(failed.failures.join(' ')).toMatch(/STELLAR_RECEIVING_ADDRESS/) + expect(failed.bodySnippet).toBe('A server error has occurred') + }) + + it('fails when validation is bypassed and a 402 challenge is issued for a bad count', async () => { + const fetchImpl = deploymentWithMethods({ + '/api/search?q=stellar+lumens&count=999': response('{}', { + status: 402, + headers: { 'payment-required': challenge() }, + }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-search-count-out-of-bounds') + expect(failed.ok).toBe(false) + expect(failed.status).toBe(402) + expect(failed.failures.join(' ')).toMatch(/expected HTTP 400, got 402/) + }) + + it('fails when the x402 challenge carries the wrong asset format', async () => { + const fetchImpl = deploymentWithMethods({ + '/api/search?q=stellar+lumens&count=3': response('{}', { + status: 402, + headers: { + 'payment-required': challenge({ asset: `USDC:${PAY_TO}` }), + 'access-control-expose-headers': 'PAYMENT-REQUIRED', + }, + }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-search-x402-challenge') + expect(failed.ok).toBe(false) + expect(failed.failures.join(' ')).toMatch(/Soroban C\.\.\. contract address/) + }) + + it('fails when the challenge header is not exposed to browser clients', async () => { + const fetchImpl = deploymentWithMethods({ + '/api/search?q=stellar+lumens&count=3': response('{}', { + status: 402, + headers: { 'payment-required': challenge() }, + }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-search-x402-challenge') + expect(failed.ok).toBe(false) + expect(failed.failures.join(' ')).toMatch(/Access-Control-Expose-Headers/) + }) + + it('records a transport failure as a failed check rather than throwing', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('getaddrinfo ENOTFOUND stellar-search-preview.vercel.app') + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + expect(summary.ok).toBe(false) + expect(summary.failed).toBe(CHECKS.length) + expect(summary.results[0].status).toBeNull() + expect(summary.results[0].failures[0]).toMatch(/request failed: getaddrinfo ENOTFOUND/) + }) + + it('keeps a response artifact for every failed check', async () => { + const fetchImpl = deploymentWithMethods({ + '/api': response('502 Bad Gateway', { status: 502, headers: { 'content-type': 'text/html' } }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-service-descriptor') + expect(failed.bodySnippet).toContain('502 Bad Gateway') + expect(failed.headers['content-type']).toBe('text/html') + expect(failed.expectedStatus).toEqual([200]) + }) + + it('truncates an oversized body in the artifact', async () => { + const fetchImpl = deploymentWithMethods({ + '/api': response('x'.repeat(5000), { status: 500, headers: { 'content-type': 'text/plain' } }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-service-descriptor') + expect(failed.bodySnippet.length).toBeLessThan(5000) + expect(failed.bodySnippet).toMatch(/… \[truncated\]$/) + }) + + it('never captures sensitive response headers into an artifact', async () => { + const fetchImpl = deploymentWithMethods({ + '/api': response('{}', { + status: 500, + headers: { 'content-type': 'application/json', authorization: 'Bearer super-secret', 'set-cookie': 'sid=abc' }, + }), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-service-descriptor') + expect(Object.keys(failed.headers)).not.toContain('authorization') + expect(Object.keys(failed.headers)).not.toContain('set-cookie') + expect(JSON.stringify(failed)).not.toContain('super-secret') + }) +}) + +// ─── Invalid input & unsupported environments ──────────────────────────────── + +describe('normalizeBaseUrl', () => { + it('accepts a full https URL and strips a trailing slash', () => { + expect(normalizeBaseUrl(`${BASE}/`)).toBe(BASE) + expect(normalizeBaseUrl(BASE)).toBe(BASE) + }) + + it('assumes https for a bare host, as Vercel deployment URLs are given', () => { + expect(normalizeBaseUrl('stellar-search-preview.vercel.app')).toBe(BASE) + }) + + it('allows http for a local preview', () => { + expect(normalizeBaseUrl('http://localhost:3001')).toBe('http://localhost:3001') + }) + + it('rejects a missing, blank, or non-string URL', () => { + expect(() => normalizeBaseUrl(undefined)).toThrow(/deployment URL is required/) + expect(() => normalizeBaseUrl(' ')).toThrow(/deployment URL is required/) + }) + + it('rejects a non-http(s) scheme', () => { + expect(() => normalizeBaseUrl('ftp://example.com')).toThrow(/must be http\(s\)/) + }) +}) + +describe('parseArgs', () => { + it('parses the URL with both artifact options', () => { + expect(parseArgs([BASE, '--json', 'out/s.json', '--markdown', 'out/s.md'])).toEqual({ + baseUrl: BASE, + options: { json: 'out/s.json', markdown: 'out/s.md' }, + }) + }) + + it('rejects an option with no value and an unknown option', () => { + expect(() => parseArgs([BASE, '--json'])).toThrow(/requires a file path/) + expect(() => parseArgs([BASE, '--json', '--markdown'])).toThrow(/requires a file path/) + expect(() => parseArgs([BASE, '--verbose'])).toThrow(/Unknown option/) + }) +}) + +// ─── Reporting ─────────────────────────────────────────────────────────────── + +describe('formatMarkdown', () => { + it('reports success with a full check table', async () => { + const summary = await runSmoke(BASE, { fetchImpl: deploymentWithMethods() }) + const md = formatMarkdown(summary) + + expect(md).toContain('## Preview smoke ✅ passed') + expect(md).toContain(BASE) + expect(md).not.toContain('### Failed endpoints') + expect(md).toContain('| ✅ |') + }) + + it('names the exact failed endpoint and embeds its response artifact', async () => { + const fetchImpl = deploymentWithMethods({ + '/api/health': response('{"boom":true}', { status: 503, headers: { 'content-type': 'application/json' } }), + }) + const md = formatMarkdown(await runSmoke(BASE, { fetchImpl })) + + expect(md).toContain('## Preview smoke ❌ failed') + expect(md).toContain('### Failed endpoints') + expect(md).toContain('`GET /api/health`') + expect(md).toContain(`${BASE}/api/health`) + expect(md).toContain('**Status:** 503 (expected 200)') + expect(md).toContain('
Response artifact') + expect(md).toContain('{\\"boom\\":true}') + }) + + it('renders "no response" for an unreachable deployment', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('connect ECONNREFUSED') + }) + const md = formatMarkdown(await runSmoke(BASE, { fetchImpl })) + + expect(md).toContain('**Status:** no response') + expect(md).toContain('connect ECONNREFUSED') + }) +}) + +// ─── Single-check contract ─────────────────────────────────────────────────── + +describe('runCheck', () => { + it('resolves a function path and reports both status and body failures', async () => { + const check = CHECKS.find((c: any) => c.id === 'api-search-missing-q') + const fetchImpl = vi.fn(async () => + response(JSON.stringify({ error: 'Payment required' }), { status: 402, headers: { 'content-type': 'application/json' } }), + ) + const result = await runCheck(check, BASE, fetchImpl) + + expect(result.ok).toBe(false) + expect(result.failures).toHaveLength(2) + expect(result.failures[0]).toMatch(/expected HTTP 400, got 402/) + expect(result.failures[1]).toMatch(/Missing required parameter: q/) + expect(result.durationMs).toBeGreaterThanOrEqual(0) + }) + + it('sends the smoke user agent and does not follow redirects', async () => { + const check = CHECKS.find((c: any) => c.id === 'static-spa-shell') + const fetchImpl = vi.fn(async () => response(SPA_SHELL, { headers: { 'content-type': 'text/html' } })) + await runCheck(check, BASE, fetchImpl) + + const [, init] = fetchImpl.mock.calls[0] + expect((init as any).headers['User-Agent']).toBe('stellar-search-smoke/1') + expect((init as any).redirect).toBe('manual') + }) +}) + +// ─── Health statistics declaration (#226) ──────────────────────────────────── + +describe('checkStatsDeclaration — health must say what it measures', () => { + const CONFIG = { status: 'ok', network: 'stellar:testnet', protocol: 'x402' } + const COUNTERS = { totalQueries: 3, totalUsdcSettled: '0.0030', avgLatencyMs: 210, uptime: '4m' } + const FIELDS = ['totalQueries', 'totalUsdcSettled', 'avgLatencyMs', 'uptime'] + + it('accepts an Express deployment that measures and reports everything', () => { + expect(checkStatsDeclaration({ ...CONFIG, ...COUNTERS, statsSupported: true, unsupportedFields: [] })).toEqual([]) + }) + + it('accepts a serverless deployment that declares the gap with a reason', () => { + expect( + checkStatsDeclaration({ + ...CONFIG, + statsSupported: false, + unsupportedFields: FIELDS, + statsUnavailableReason: 'Serverless functions are stateless.', + }), + ).toEqual([]) + }) + + it('rejects the original bug: counters silently omitted with no declaration', () => { + expect(checkStatsDeclaration({ ...CONFIG })).toEqual([ + expect.stringMatching(/must declare `statsSupported`/), + ]) + }) + + it('rejects a runtime that claims to measure but omits the values', () => { + const failures = checkStatsDeclaration({ ...CONFIG, statsSupported: true, unsupportedFields: [] }) + expect(failures).toHaveLength(FIELDS.length) + for (const field of FIELDS) { + expect(failures.join(' ')).toContain(field) + } + }) + + it('rejects an unsupported declaration with no explanation', () => { + expect( + checkStatsDeclaration({ ...CONFIG, statsSupported: false, unsupportedFields: FIELDS }), + ).toEqual([expect.stringMatching(/no statsUnavailableReason/)]) + }) + + it('rejects a stale value left behind on a field declared unsupported', () => { + const failures = checkStatsDeclaration({ + ...CONFIG, + totalQueries: 0, + statsSupported: false, + unsupportedFields: FIELDS, + statsUnavailableReason: 'Stateless.', + }) + expect(failures).toEqual([expect.stringMatching(/`totalQueries` is declared unsupported but a value was still reported/)]) + }) + + it('rejects contradictory declarations and malformed field lists', () => { + expect( + checkStatsDeclaration({ ...CONFIG, ...COUNTERS, statsSupported: true, unsupportedFields: ['uptime'] }), + ).toEqual([expect.stringMatching(/statsSupported is true but unsupportedFields is not empty/)]) + + expect(checkStatsDeclaration({ ...CONFIG, statsSupported: false, unsupportedFields: 'uptime' })).toEqual([ + expect.stringMatching(/must declare `unsupportedFields` as an array/), + ]) + }) +}) + +describe('smoke suite — health declaration failures', () => { + it('fails the health check when a deployment omits counters without declaring them', async () => { + const fetchImpl = deploymentWithMethods({ + '/api/health': response( + JSON.stringify({ + status: 'ok', + network: 'stellar:testnet', + protocol: 'x402', + receivingAddressConfigured: true, + }), + { headers: { 'content-type': 'application/json' } }, + ), + }) + const summary = await runSmoke(BASE, { fetchImpl }) + + const failed = summary.results.find((r: any) => r.id === 'api-health-env-wiring') + expect(failed.ok).toBe(false) + expect(failed.status).toBe(200) + expect(failed.failures.join(' ')).toMatch(/must declare `statsSupported`/) + expect(failed.failures.join(' ')).toMatch(/real zero/) + }) +}) diff --git a/server/health.test.ts b/server/health.test.ts index d5f6e03..46ef09d 100644 --- a/server/health.test.ts +++ b/server/health.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeAll } from 'vitest' import request from 'supertest' +import { MEASURED_STAT_FIELDS, resolveStat, hasAnyStats } from '../src/lib/serverHealth' // Mock x402 and Groq before importing app vi.mock('@x402/express', () => ({ @@ -50,6 +51,35 @@ describe('GET /health and GET /', () => { expect(typeof res.body.receivingAddressConfigured).toBe('boolean') }) + // ─── Statistics declaration (#226) ────────────────────────────────────── + // Express keeps the counters in the process serving the paid routes, so it + // measures everything and says so. The declaration is what lets a consumer + // tell a real `totalQueries: 0` apart from a runtime that never measured it. + + it('GET /health declares that it measures every activity statistic', async () => { + const res = await request(app).get('/health') + expect(res.body.statsSupported).toBe(true) + expect(res.body.unsupportedFields).toEqual([]) + expect(res.body.statsUnavailableReason).toBeUndefined() + }) + + it('GET /health resolves every statistic as available through the shared contract', async () => { + const res = await request(app).get('/health') + expect(hasAnyStats(res.body)).toBe(true) + for (const field of MEASURED_STAT_FIELDS) { + expect(resolveStat(res.body, field).available).toBe(true) + } + }) + + it('GET /health reports a zero counter as a real measurement, not an absence', async () => { + // Nothing has been searched in this suite, so the counters are genuinely + // zero — and must resolve as available zeros rather than "unavailable". + const res = await request(app).get('/health') + const queries = resolveStat(res.body, 'totalQueries') + expect(queries).toEqual({ available: true, value: res.body.totalQueries }) + expect(typeof res.body.totalQueries).toBe('number') + }) + it('GET / returns service metadata with paid routes', async () => { const res = await request(app).get('/') expect(res.status).toBe(200) diff --git a/server/index.ts b/server/index.ts index 1725765..7e2c39a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -32,6 +32,17 @@ import { USDC_CONTRACT } from '../src/lib/constants' import { consumePaymentPayload, extractPaymentIdentifier } from '../src/lib/paymentIntegrity' +import { + validateCount, + validateFreshness, + FRESHNESS_TBS, + SEARCH_COUNT, + IMAGES_COUNT, + NEWS_COUNT, + type CountBounds, + type Freshness, +} from '../src/lib/paramValidation' +import { declareStatsSupported, type ServerHealthResponse } from '../src/lib/serverHealth' import { formatConfigurationError, readServerConfig } from '../src/lib/config' import { normalizeOrganicResults, @@ -338,6 +349,74 @@ app.use((req, res, next) => { next(); }); +// ─── Shared parameter validation for paid routes (#188) ────────────────── +// Registered BEFORE the x402 middleware on purpose: a malformed `count` or +// `freshness` is rejected with 400 without ever consulting the payment +// adapter, the facilitator, or Serper. That keeps a caller from being +// charged — or from being handed a 402 challenge — for a request the server +// was always going to refuse. + +interface PaidRouteParamSpec { + bounds: CountBounds + /** `/images` has no Serper date filter, so `freshness` is not accepted there. */ + supportsFreshness: boolean + /** GET routes carry params in the query string, POST routes in the JSON body. */ + source: 'query' | 'body' +} + +const PAID_ROUTE_PARAMS: Record = { + 'GET /search': { bounds: SEARCH_COUNT, supportsFreshness: true, source: 'query' }, + 'GET /images': { bounds: IMAGES_COUNT, supportsFreshness: false, source: 'query' }, + 'GET /news': { bounds: NEWS_COUNT, supportsFreshness: true, source: 'query' }, + 'POST /search/batch': { bounds: SEARCH_COUNT, supportsFreshness: true, source: 'body' }, + 'POST /jobs': { bounds: SEARCH_COUNT, supportsFreshness: true, source: 'body' }, +} + +export interface ValidatedPaidParams { + /** Forwarded to Serper as `num`. */ + count: number + freshness?: Freshness + /** Serper `tbs` date filter; undefined when no freshness was requested. */ + tbs?: string +} + +/** Reads the params a paid route validated for this request. */ +function paidParams(req: Request, bounds: CountBounds): ValidatedPaidParams { + // The middleware below always populates this for the routes in the table; + // the fallback only guards a handler being exercised in isolation. + return ((req as any).validatedParams as ValidatedPaidParams | undefined) ?? { count: bounds.default } +} + +app.use((req, res, next) => { + const spec = PAID_ROUTE_PARAMS[`${req.method} ${req.path}`] + if (!spec) return next() + + const raw = (spec.source === 'body' ? req.body : req.query) ?? {} + + const count = validateCount((raw as Record).count, spec.bounds) + if (!count.ok) { + const errorBody: ApiErrorResponse = { error: count.error } + return res.status(400).json(errorBody) + } + + const params: ValidatedPaidParams = { count: count.value } + + if (spec.supportsFreshness) { + const freshness = validateFreshness((raw as Record).freshness) + if (!freshness.ok) { + const errorBody: ApiErrorResponse = { error: freshness.error } + return res.status(400).json(errorBody) + } + if (freshness.value) { + params.freshness = freshness.value + params.tbs = FRESHNESS_TBS[freshness.value] + } + } + + ;(req as any).validatedParams = params + next() +}) + app.use(paymentMiddlewareFromConfig(x402Routes, facilitatorClient, schemes)) // ─── Payment Replay Protection Middleware ───────────────────────────────── @@ -425,7 +504,7 @@ app.get('/search', async (req: Request, res: Response) => { let txHash: string | null = null try { - const { q, count = '5', freshness } = req.query as Record + const { q } = req.query as Record const v = validateQuery(q) if (!v.ok) { @@ -434,24 +513,17 @@ app.get('/search', async (req: Request, res: Response) => { } const cleanQ = v.cleanQ + // `count`/`freshness` were validated up front by the paid-route + // middleware (#188), so no clamping or enum lookup is needed here. + const { count, tbs } = paidParams(req, SEARCH_COUNT) + const t0 = Date.now() const requestBody: Record = { q: cleanQ, - num: Math.min(parseInt(count) || 5, 20), - } - - // Add freshness filter if provided (Serper supports date filters) - if (freshness) { - const dateFilters: Record = { - 'pd': 'qdr:d', // past day - 'pw': 'qdr:w', // past week - 'pm': 'qdr:m', // past month - } - if (dateFilters[freshness]) { - requestBody.tbs = dateFilters[freshness] - } + num: count, } + if (tbs) requestBody.tbs = tbs const serperRes = await fetch('https://google.serper.dev/search', { method: 'POST', @@ -562,7 +634,7 @@ app.get('/images', async (req: Request, res: Response) => { let txHash: string | null = null try { - const { q, count = '10' } = req.query as Record + const { q } = req.query as Record const v = validateQuery(q) if (!v.ok) { @@ -571,6 +643,9 @@ app.get('/images', async (req: Request, res: Response) => { } const cleanQ = v.cleanQ + // Images have no date filter, so only `count` is validated (1..10). + const { count } = paidParams(req, IMAGES_COUNT) + const t0 = Date.now() const serperRes = await fetch('https://google.serper.dev/images', { @@ -581,7 +656,7 @@ app.get('/images', async (req: Request, res: Response) => { }, body: JSON.stringify({ q: cleanQ, - num: Math.min(parseInt(count) || 10, 10), + num: count, }), }) @@ -635,7 +710,7 @@ app.get('/news', async (req: Request, res: Response) => { let txHash: string | null = null try { - const { q, count = '10', freshness } = req.query as Record + const { q } = req.query as Record const v = validateQuery(q) if (!v.ok) { @@ -644,23 +719,15 @@ app.get('/news', async (req: Request, res: Response) => { } const cleanQ = v.cleanQ + const { count, tbs } = paidParams(req, NEWS_COUNT) + const t0 = Date.now() const requestBody: Record = { q: cleanQ, - num: Math.min(parseInt(count) || 10, 20), - } - - if (freshness) { - const dateFilters: Record = { - 'pd': 'qdr:d', - 'pw': 'qdr:w', - 'pm': 'qdr:m', - } - if (dateFilters[freshness]) { - requestBody.tbs = dateFilters[freshness] - } + num: count, } + if (tbs) requestBody.tbs = tbs const serperRes = await fetch('https://google.serper.dev/news', { method: 'POST', @@ -730,7 +797,9 @@ app.post('/search/batch', async (req: Request, res: Response) => { } } - const { queries, count: rawCount, freshness } = (req.body || {}) as { queries?: unknown; count?: unknown; freshness?: string } + const { queries } = (req.body || {}) as { queries?: unknown } + // Validated up front by the paid-route middleware (#188). + const { count: parsedCount, tbs } = paidParams(req, SEARCH_COUNT) if (!Array.isArray(queries) || queries.length === 0) { return res.status(400).json({ error: 'queries array required (1..10)' }) @@ -748,7 +817,6 @@ app.post('/search/batch', async (req: Request, res: Response) => { if (!v.ok) return res.status(400).json({ error: `Invalid query "${String(q).slice(0, 30)}": ${v.error}`, index: queries.indexOf(q) }) cleanQueries.push(v.cleanQ) } - const parsedCount = Math.min(Math.max(parseInt(String(rawCount ?? '5')) || 5, 1), 20) const paymentHeader = (req.headers['payment-signature'] || req.headers['x-payment'] || req.headers['X-PAYMENT'] || req.headers['x-payment-response'] || req.headers['authorization']) as string | undefined if (!paymentHeader) { @@ -829,10 +897,7 @@ app.post('/search/batch', async (req: Request, res: Response) => { const t0 = Date.now() try { const requestBody: Record = { q, num: parsedCount } - if (freshness) { - const dateFilters: Record = { 'pd': 'qdr:d', 'pw': 'qdr:w', 'pm': 'qdr:m' } - if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] - } + if (tbs) requestBody.tbs = tbs const serperRes = await fetch('https://google.serper.dev/search', { method: 'POST', headers: { 'X-API-KEY': SERPER_API_KEY, 'Content-Type': 'application/json' }, @@ -918,12 +983,13 @@ app.post('/jobs', async (req: Request, res: Response) => { } } - const { query, count = '5', freshness, webhookUrl, webhookSecret } = (req.body || {}) as { query?: unknown; count?: unknown; freshness?: string; webhookUrl?: string; webhookSecret?: string } + const { query, webhookUrl, webhookSecret } = (req.body || {}) as { query?: unknown; webhookUrl?: string; webhookSecret?: string } const v = validateQuery(query) if (!v.ok) return res.status(400).json({ error: v.error }) const cleanQ = v.cleanQ - const safeCount = Math.min(Math.max(parseInt(String(count)) || 5, 1), 20) + // Validated up front by the paid-route middleware (#188). + const { count: safeCount, freshness, tbs } = paidParams(req, SEARCH_COUNT) // Webhook validation (SSRF + https) if (webhookUrl) { @@ -993,10 +1059,7 @@ app.post('/jobs', async (req: Request, res: Response) => { const t0 = Date.now() try { const requestBody: Record = { q: cleanQ, num: safeCount } - if (freshness) { - const dateFilters: Record = { 'pd': 'qdr:d', 'pw': 'qdr:w', 'pm': 'qdr:m' } - if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] - } + if (tbs) requestBody.tbs = tbs const serperRes = await fetch('https://google.serper.dev/search', { method: 'POST', headers: { 'X-API-KEY': SERPER_API_KEY, 'Content-Type': 'application/json' }, @@ -1066,7 +1129,11 @@ app.get('/health', (_req: Request, res: Response) => { const up = Math.floor((Date.now() - stats.startTime) / 1000) const uptime = up < 60 ? `${up}s` : up < 3600 ? `${Math.floor(up / 60)}m` : `${Math.floor(up / 3600)}h` - res.json({ + // Express holds the counters in the same process that serves the paid + // routes, so it measures every statistic and says so (#226). The declaration + // is what lets a consumer tell a real `totalQueries: 0` on a freshly started + // server apart from a runtime that never measured it at all. + const body: ServerHealthResponse = { status: 'ok', network: NETWORK, pricePerQuery: '0.001 USDC', @@ -1079,7 +1146,10 @@ app.get('/health', (_req: Request, res: Response) => { serperApiConfigured: !!SERPER_API_KEY, groqApiConfigured: !!GROQ_API_KEY, receivingAddressConfigured: !!RECEIVING_ADDRESS, - }) + ...declareStatsSupported(), + } + + res.json(body) }) // ─── POST /ai/chat ──────────────────────────────────────────────────────── diff --git a/src/components/ui/StatsGrid.test.tsx b/src/components/ui/StatsGrid.test.tsx new file mode 100644 index 0000000..e727326 --- /dev/null +++ b/src/components/ui/StatsGrid.test.tsx @@ -0,0 +1,234 @@ +/** + * src/components/ui/StatsGrid.test.tsx + * + * Covers the statistics panel's handling of the shared health contract (#226). + * + * The bug this guards: a Vercel deployment omits the activity counters, the + * grid coalesced the absent values to `0` / `'0.00'`, and the result was + * "0 queries · $0.00 settled · 0ms" rendered beside a live green "SERVER + * ONLINE" pulse. These tests pin the three distinct states — genuine zero, + * unmeasured, and unreachable — and the affordances that separate them. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, act } from '@testing-library/react' +import { StatsGrid } from './StatsGrid' +import { + MEASURED_STAT_FIELDS, + SERVERLESS_STATS_UNAVAILABLE_REASON, + declareStatsSupported, + declareStatsUnsupported, +} from '../../lib/serverHealth' + +const fetchServerStats = vi.fn() + +vi.mock('../../lib/stellar', () => ({ fetchServerStats: (...args: unknown[]) => fetchServerStats(...args) })) +vi.mock('../../hooks/usePageVisible', () => ({ usePageVisible: () => true })) +vi.mock('framer-motion', () => ({ + motion: new Proxy({} as Record, { + get: (_target, tag: string) => + ({ children, ...props }: any) => { + const skip = ['initial', 'animate', 'transition', 'exit', 'whileHover', 'whileTap'] + const rest = Object.fromEntries(Object.entries(props).filter(([k]) => !skip.includes(k))) + return
{children}
+ }, + }), +})) + +const CONFIG = { + status: 'ok', + network: 'stellar:testnet', + pricePerQuery: '0.001 USDC', + protocol: 'x402', + facilitator: 'https://www.x402.org/facilitator', + serperApiConfigured: true, + groqApiConfigured: true, + receivingAddressConfigured: true, +} + +const EXPRESS_ACTIVE = { + ...CONFIG, + totalQueries: 1234, + totalUsdcSettled: '1.2340', + avgLatencyMs: 412, + uptime: '7m', + ...declareStatsSupported(), +} + +/** A real Express server that has genuinely served nothing yet. */ +const EXPRESS_FRESH = { + ...CONFIG, + totalQueries: 0, + totalUsdcSettled: '0.0000', + avgLatencyMs: 0, + uptime: '3s', + ...declareStatsSupported(), +} + +const SERVERLESS = { + ...CONFIG, + timestamp: '2026-09-02T12:00:00.000Z', + ...declareStatsUnsupported(SERVERLESS_STATS_UNAVAILABLE_REASON), +} + +const value = (field: string) => screen.getByTestId(`stat-value-${field}`) + +beforeEach(() => { + fetchServerStats.mockReset() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +// ─── Primary flow: a runtime that measures ─────────────────────────────────── + +describe('StatsGrid — Express deployment (statistics supported)', () => { + it('renders live counters and marks the server online', async () => { + fetchServerStats.mockResolvedValue(EXPRESS_ACTIVE) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('1,234')) + expect(value('totalUsdcSettled')).toHaveTextContent('$1.2340') + expect(value('avgLatencyMs')).toHaveTextContent('412ms') + expect(value('uptime')).toHaveTextContent('7m') + expect(screen.getByText(/SERVER ONLINE/)).toBeInTheDocument() + }) + + it('renders a genuine zero as 0, not as unavailable', async () => { + // A freshly started server really has served no queries. That is a + // measurement and must display as such. + fetchServerStats.mockResolvedValue(EXPRESS_FRESH) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('0')) + expect(value('totalUsdcSettled')).toHaveTextContent('$0.0000') + expect(value('avgLatencyMs')).toHaveTextContent('0ms') + expect(value('uptime')).toHaveTextContent('3s') + + for (const field of MEASURED_STAT_FIELDS) { + expect(value(field)).toHaveAttribute('data-available', 'true') + expect(value(field)).not.toHaveTextContent('n/a') + } + expect(screen.queryByRole('note')).not.toBeInTheDocument() + }) + + it('shows the live pulse only for measured cards', async () => { + fetchServerStats.mockResolvedValue(EXPRESS_ACTIVE) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('1,234')) + for (const field of MEASURED_STAT_FIELDS) { + expect(screen.getByTestId(`live-indicator-${field}`)).toBeInTheDocument() + } + }) +}) + +// ─── The issue: a runtime that does not measure ────────────────────────────── + +describe('StatsGrid — serverless deployment (statistics unsupported)', () => { + it('renders n/a rather than zero for every unmeasured counter', async () => { + fetchServerStats.mockResolvedValue(SERVERLESS) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('n/a')) + for (const field of MEASURED_STAT_FIELDS) { + expect(value(field)).toHaveTextContent('n/a') + expect(value(field)).toHaveAttribute('data-available', 'false') + } + // The specific regression: none of these may read as a real measurement. + expect(screen.queryByText('$0.00')).not.toBeInTheDocument() + expect(screen.queryByText('0ms')).not.toBeInTheDocument() + }) + + it('still reports the server as online, because it is', async () => { + fetchServerStats.mockResolvedValue(SERVERLESS) + render() + + await waitFor(() => expect(screen.getByText(/SERVER ONLINE/)).toBeInTheDocument()) + }) + + it('explains once, at the panel level, why the counters are missing', async () => { + fetchServerStats.mockResolvedValue(SERVERLESS) + render() + + const note = await screen.findByRole('note') + expect(note).toHaveTextContent(/Live counters are not available on this deployment/) + expect(note).toHaveTextContent(/stateless and scale to zero/) + }) + + it('suppresses the live pulse and exposes the reason to assistive tech', async () => { + fetchServerStats.mockResolvedValue(SERVERLESS) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('n/a')) + for (const field of MEASURED_STAT_FIELDS) { + // No pulse: it signals "live measurement". + expect(screen.queryByTestId(`live-indicator-${field}`)).not.toBeInTheDocument() + expect(value(field)).toHaveAttribute('title', SERVERLESS_STATS_UNAVAILABLE_REASON) + } + expect(screen.getAllByText(/is not reported by this deployment/)).toHaveLength(MEASURED_STAT_FIELDS.length) + }) +}) + +// ─── Failure and boundary paths ────────────────────────────────────────────── + +describe('StatsGrid — failure and boundary paths', () => { + it('shows n/a and an offline server when health cannot be reached', async () => { + fetchServerStats.mockResolvedValue(null) + render() + + await waitFor(() => expect(screen.getByText(/SERVER OFFLINE/)).toBeInTheDocument()) + for (const field of MEASURED_STAT_FIELDS) { + expect(value(field)).toHaveTextContent('n/a') + expect(value(field)).toHaveAttribute('data-available', 'false') + } + }) + + it('does not keep showing stale counters after the server goes away', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + fetchServerStats.mockResolvedValueOnce(EXPRESS_ACTIVE).mockResolvedValue(null) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('1,234')) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + await waitFor(() => expect(screen.getByText(/SERVER OFFLINE/)).toBeInTheDocument()) + expect(value('totalQueries')).toHaveTextContent('n/a') + }) + + it('treats a pre-contract payload with no counters as unavailable, not zero', async () => { + // A deployment that predates #226: no declaration and no values. + fetchServerStats.mockResolvedValue({ ...CONFIG }) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('n/a')) + expect(await screen.findByRole('note')).toHaveTextContent(/did not declare/) + }) + + it('still reads counters from a pre-contract Express payload', async () => { + fetchServerStats.mockResolvedValue({ ...CONFIG, totalQueries: 7, totalUsdcSettled: '0.0070', avgLatencyMs: 99, uptime: '1m' }) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('7')) + expect(screen.queryByRole('note')).not.toBeInTheDocument() + }) + + it('renders a mixed payload per field', async () => { + fetchServerStats.mockResolvedValue({ + ...EXPRESS_ACTIVE, + unsupportedFields: ['avgLatencyMs'], + statsUnavailableReason: 'Latency sampling is disabled on this deployment.', + }) + render() + + await waitFor(() => expect(value('totalQueries')).toHaveTextContent('1,234')) + expect(value('avgLatencyMs')).toHaveTextContent('n/a') + expect(value('avgLatencyMs')).toHaveAttribute('title', 'Latency sampling is disabled on this deployment.') + // Some statistics are live, so no panel-wide disclaimer. + expect(screen.queryByRole('note')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/ui/StatsGrid.tsx b/src/components/ui/StatsGrid.tsx index c5db55f..64f74b9 100644 --- a/src/components/ui/StatsGrid.tsx +++ b/src/components/ui/StatsGrid.tsx @@ -1,53 +1,66 @@ import { useCallback, useEffect, useState } from 'react' import { motion } from 'framer-motion' -import { TrendingUp, Zap, Clock, Shield } from 'lucide-react' +import { TrendingUp, Zap, Clock, Shield, HelpCircle } from 'lucide-react' import { fetchServerStats } from '../../lib/stellar' import { usePageVisible } from '../../hooks/usePageVisible' +import { + resolveStat, + statsUnavailableReason, + type MeasuredStatField, + type StatResolution, +} from '../../lib/serverHealth' -interface ServerStats { - totalQueries: number - totalUsdcSettled: string - avgLatencyMs: number - uptime: string - status: 'online' | 'offline' - [key: string]: any // Add index signature +type ConnectionStatus = 'online' | 'offline' + +interface StatCard { + key: MeasuredStatField + label: string + Icon: typeof TrendingUp + color: string + fmt: (value: number | string) => string } -const CARDS = [ - { key: 'totalQueries', label: 'Total Queries', Icon: TrendingUp, color: '#00f5ff', fmt: (v: unknown) => Number(v).toLocaleString() }, - { key: 'totalUsdcSettled', label: 'USDC Settled', Icon: Zap, color: '#ffb800', fmt: (v: unknown) => `$${v}` }, - { key: 'avgLatencyMs', label: 'Avg Latency', Icon: Clock, color: '#39ff14', fmt: (v: unknown) => `${v}ms` }, - { key: 'uptime', label: 'Uptime', Icon: Shield, color: '#7dd3fc', fmt: (v: unknown) => String(v) }, +const CARDS: StatCard[] = [ + { key: 'totalQueries', label: 'Total Queries', Icon: TrendingUp, color: '#00f5ff', fmt: (v) => Number(v).toLocaleString() }, + { key: 'totalUsdcSettled', label: 'USDC Settled', Icon: Zap, color: '#ffb800', fmt: (v) => `$${v}` }, + { key: 'avgLatencyMs', label: 'Avg Latency', Icon: Clock, color: '#39ff14', fmt: (v) => `${v}ms` }, + { key: 'uptime', label: 'Uptime', Icon: Shield, color: '#7dd3fc', fmt: (v) => String(v) }, ] +/** Rendered in place of a number the deployment does not measure. */ +const UNAVAILABLE_PLACEHOLDER = 'n/a' + interface StatsGridProps { /** Polling interval in milliseconds. Defaults to 10 seconds. */ pollingIntervalMs?: number } +/** + * Live server statistics. + * + * Values are read through `resolveStat` rather than straight off the health + * payload (#226). A runtime that does not measure a statistic — a Vercel + * function, which is stateless — declares it unsupported, and the card renders + * `n/a` with the reason instead of a `0` that would read as a real + * measurement. A genuine zero from a freshly started Express server still + * renders as `0`. + */ export function StatsGrid({ pollingIntervalMs = 10_000 }: StatsGridProps) { - const [stats, setStats] = useState({ - totalQueries: 0, - totalUsdcSettled: '0.00', - avgLatencyMs: 0, - uptime: '—', - status: 'offline', - }) + const [health, setHealth] = useState(null) + const [status, setStatus] = useState('offline') const load = useCallback(async () => { - const data = await fetchServerStats() - if (data) { - const next: ServerStats = { - totalQueries: data.totalQueries ?? 0, - totalUsdcSettled: data.totalUsdcSettled ?? '0.00', - avgLatencyMs: data.avgLatencyMs ?? 0, - uptime: data.uptime ?? '—', - status: 'online', - } - setStats(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next) - } else { - setStats(prev => ({ ...prev, status: 'offline' })) - } + const data = await fetchServerStats() + if (data) { + // Only re-render when the payload actually changed; the poll runs every + // few seconds and most ticks are identical. + setHealth((previous: unknown) => + JSON.stringify(previous) === JSON.stringify(data) ? previous : data, + ) + setStatus('online') + } else { + setStatus('offline') + } }, []) const isVisible = usePageVisible() @@ -64,52 +77,98 @@ export function StatsGrid({ pollingIntervalMs = 10_000 }: StatsGridProps) { return () => clearInterval(id) }, [load, pollingIntervalMs, isVisible]) + // While offline there is no payload at all, so every card resolves to + // unavailable for the "unreachable" reason rather than showing stale values. + const payload = status === 'online' ? health : null + const panelReason = status === 'online' ? statsUnavailableReason(payload) : null + return (
- {CARDS.map(({ key, label, Icon, color, fmt }, i) => ( - -
-
- + {CARDS.map(({ key, label, Icon, color, fmt }, i) => { + const resolved: StatResolution = resolveStat(payload, key) + const display = resolved.available ? fmt(resolved.value) : UNAVAILABLE_PLACEHOLDER + + return ( + +
+
+ +
+ {resolved.available ? ( + // The pulse means "this is a live measurement" — it must not + // appear on a card showing an unmeasured field. + + ) : ( +
- -
-

- {fmt(stats[key])} -

-

- {label} -

-
- - ))} +

+ {display} +

+

+ {label} +

+ {!resolved.available && ( + {label} is not reported by this deployment. {resolved.reason} + )} +
+ + ) + })} -
-
- - SERVER {stats.status === 'online' ? 'ONLINE' : 'OFFLINE — run: npm run server'} - +
+
+
+ + SERVER {status === 'online' ? 'ONLINE' : 'OFFLINE — run: npm run server'} + +
+ {panelReason && ( + // One explanation for the whole panel beats repeating it on four + // cards, and it keeps "online but not measuring" from reading as a + // quiet server. +

+ Live counters are not available on this deployment. {panelReason} +

+ )}
) diff --git a/src/lib/serverHealth.test.ts b/src/lib/serverHealth.test.ts new file mode 100644 index 0000000..efb9cb6 --- /dev/null +++ b/src/lib/serverHealth.test.ts @@ -0,0 +1,200 @@ +/** + * src/lib/serverHealth.test.ts + * + * Covers the shared `/health` statistics contract (#226). + * + * The whole point of the contract is one distinction: a statistic a runtime + * measured and found to be zero is NOT the same as a statistic the runtime + * never measured. These tests pin that distinction, the declarations each + * runtime makes, and the degraded paths — unreachable server, malformed + * payload, and a deployment that predates the contract. + */ + +import { describe, it, expect } from 'vitest' +import { + MEASURED_STAT_FIELDS, + SERVERLESS_STATS_UNAVAILABLE_REASON, + UNDECLARED_STAT_REASON, + SERVER_UNREACHABLE_REASON, + declareStatsSupported, + declareStatsUnsupported, + resolveStat, + hasAnyStats, + statsUnavailableReason, +} from './serverHealth' + +const CONFIG = { + status: 'ok' as const, + network: 'stellar:testnet', + pricePerQuery: '0.001 USDC', + protocol: 'x402' as const, + facilitator: 'https://www.x402.org/facilitator', + serperApiConfigured: true, + groqApiConfigured: true, + receivingAddressConfigured: true, +} + +/** An Express payload: real counters, declared as measured. */ +const expressHealth = (stats: Partial> = {}) => ({ + ...CONFIG, + totalQueries: 12, + totalUsdcSettled: '0.0120', + avgLatencyMs: 384, + uptime: '7m', + ...declareStatsSupported(), + ...stats, +}) + +/** A Vercel payload: configuration only, gaps declared. */ +const serverlessHealth = () => ({ + ...CONFIG, + timestamp: '2026-09-02T12:00:00.000Z', + ...declareStatsUnsupported(SERVERLESS_STATS_UNAVAILABLE_REASON), +}) + +// ─── Declarations ──────────────────────────────────────────────────────────── + +describe('stat declarations', () => { + it('declareStatsSupported marks every field as measured', () => { + expect(declareStatsSupported()).toEqual({ statsSupported: true, unsupportedFields: [] }) + }) + + it('declareStatsUnsupported lists every measured field with a reason', () => { + const declaration = declareStatsUnsupported(SERVERLESS_STATS_UNAVAILABLE_REASON) + expect(declaration.statsSupported).toBe(false) + expect(declaration.unsupportedFields).toEqual([...MEASURED_STAT_FIELDS]) + expect(declaration.statsUnavailableReason).toBe(SERVERLESS_STATS_UNAVAILABLE_REASON) + }) + + it('returns a fresh array so a caller cannot mutate the shared field list', () => { + const first = declareStatsUnsupported('because') + first.unsupportedFields.push('totalQueries') + expect(declareStatsUnsupported('because').unsupportedFields).toEqual([...MEASURED_STAT_FIELDS]) + }) + + it('names the four fields the UI renders', () => { + expect(MEASURED_STAT_FIELDS).toEqual(['totalQueries', 'totalUsdcSettled', 'avgLatencyMs', 'uptime']) + }) +}) + +// ─── The distinction this contract exists for ──────────────────────────────── + +describe('resolveStat — a measured zero is not an unmeasured field', () => { + it('resolves a genuine zero from a freshly started server as an available 0', () => { + // The regression this contract prevents: a brand-new Express server really + // has served no queries, and that IS a measurement worth showing. + const fresh = expressHealth({ totalQueries: 0, totalUsdcSettled: '0.0000', avgLatencyMs: 0, uptime: '3s' }) + + expect(resolveStat(fresh, 'totalQueries')).toEqual({ available: true, value: 0 }) + expect(resolveStat(fresh, 'avgLatencyMs')).toEqual({ available: true, value: 0 }) + expect(resolveStat(fresh, 'totalUsdcSettled')).toEqual({ available: true, value: '0.0000' }) + expect(resolveStat(fresh, 'uptime')).toEqual({ available: true, value: '3s' }) + }) + + it('resolves a serverless deployment as unavailable, never as zero', () => { + const health = serverlessHealth() + for (const field of MEASURED_STAT_FIELDS) { + const resolved = resolveStat(health, field) + expect(resolved.available).toBe(false) + expect(resolved).not.toHaveProperty('value') + if (!resolved.available) expect(resolved.reason).toBe(SERVERLESS_STATS_UNAVAILABLE_REASON) + } + }) + + it('resolves real Express counters as available', () => { + const health = expressHealth() + expect(resolveStat(health, 'totalQueries')).toEqual({ available: true, value: 12 }) + expect(resolveStat(health, 'totalUsdcSettled')).toEqual({ available: true, value: '0.0120' }) + expect(resolveStat(health, 'avgLatencyMs')).toEqual({ available: true, value: 384 }) + expect(resolveStat(health, 'uptime')).toEqual({ available: true, value: '7m' }) + }) +}) + +// ─── Boundaries ────────────────────────────────────────────────────────────── + +describe('resolveStat — boundaries', () => { + it('honours a per-field opt-out while other fields stay available', () => { + const partial = { ...expressHealth(), statsSupported: true, unsupportedFields: ['avgLatencyMs'], statsUnavailableReason: 'Latency sampling is disabled.' } + + expect(resolveStat(partial, 'totalQueries')).toEqual({ available: true, value: 12 }) + expect(resolveStat(partial, 'avgLatencyMs')).toEqual({ available: false, reason: 'Latency sampling is disabled.' }) + }) + + it('lets an explicit declaration win over a stale value left in the payload', () => { + // A runtime that stops measuring must not keep publishing the last number. + const stale = { ...serverlessHealth(), totalQueries: 999 } + expect(resolveStat(stale, 'totalQueries')).toEqual({ + available: false, + reason: SERVERLESS_STATS_UNAVAILABLE_REASON, + }) + }) + + it('treats an absent field on a pre-contract deployment as undeclared, not zero', () => { + const legacy = { ...CONFIG } // no declaration, no counters + for (const field of MEASURED_STAT_FIELDS) { + expect(resolveStat(legacy, field)).toEqual({ available: false, reason: UNDECLARED_STAT_REASON }) + } + }) + + it('still reads values from a pre-contract Express deployment', () => { + const legacy = { ...CONFIG, totalQueries: 4, totalUsdcSettled: '0.0040', avgLatencyMs: 210, uptime: '2m' } + expect(resolveStat(legacy, 'totalQueries')).toEqual({ available: true, value: 4 }) + expect(resolveStat(legacy, 'uptime')).toEqual({ available: true, value: '2m' }) + }) + + it('falls back to the undeclared reason when a declaration omits its explanation', () => { + const noReason = { ...CONFIG, statsSupported: false, unsupportedFields: [...MEASURED_STAT_FIELDS] } + expect(resolveStat(noReason, 'uptime')).toEqual({ available: false, reason: UNDECLARED_STAT_REASON }) + + const blankReason = { ...noReason, statsUnavailableReason: ' ' } + expect(resolveStat(blankReason, 'uptime')).toEqual({ available: false, reason: UNDECLARED_STAT_REASON }) + }) +}) + +// ─── Failure paths ─────────────────────────────────────────────────────────── + +describe('resolveStat — failure paths', () => { + it('reports an unreachable server rather than throwing', () => { + for (const bad of [null, undefined, 'not json', 42, true]) { + expect(resolveStat(bad, 'totalQueries')).toEqual({ available: false, reason: SERVER_UNREACHABLE_REASON }) + } + }) + + it('rejects a non-finite or wrongly typed value instead of rendering it', () => { + for (const value of [NaN, Infinity, -Infinity, null, {}, [], '', ' ']) { + const resolved = resolveStat({ ...CONFIG, ...declareStatsSupported(), avgLatencyMs: value }, 'avgLatencyMs') + expect(resolved).toEqual({ available: false, reason: UNDECLARED_STAT_REASON }) + } + }) + + it('survives an unsupportedFields value that is not an array', () => { + const malformed = { ...expressHealth(), unsupportedFields: 'totalQueries' } + expect(resolveStat(malformed, 'totalQueries')).toEqual({ available: true, value: 12 }) + }) +}) + +// ─── Panel-level helpers ───────────────────────────────────────────────────── + +describe('hasAnyStats / statsUnavailableReason', () => { + it('reports statistics as available for Express, including an all-zero server', () => { + expect(hasAnyStats(expressHealth())).toBe(true) + expect(hasAnyStats(expressHealth({ totalQueries: 0, avgLatencyMs: 0 }))).toBe(true) + expect(statsUnavailableReason(expressHealth())).toBeNull() + }) + + it('reports a single panel-level reason for a serverless deployment', () => { + expect(hasAnyStats(serverlessHealth())).toBe(false) + expect(statsUnavailableReason(serverlessHealth())).toBe(SERVERLESS_STATS_UNAVAILABLE_REASON) + }) + + it('reports statistics as available when only some fields are opted out', () => { + const partial = { ...expressHealth(), unsupportedFields: ['avgLatencyMs'] } + expect(hasAnyStats(partial)).toBe(true) + expect(statsUnavailableReason(partial)).toBeNull() + }) + + it('reports the unreachable reason when there is no payload', () => { + expect(hasAnyStats(null)).toBe(false) + expect(statsUnavailableReason(null)).toBe(SERVER_UNREACHABLE_REASON) + }) +}) diff --git a/src/lib/serverHealth.ts b/src/lib/serverHealth.ts new file mode 100644 index 0000000..78dc5ff --- /dev/null +++ b/src/lib/serverHealth.ts @@ -0,0 +1,187 @@ +/** + * serverHealth.ts + * + * The shared `/health` statistics contract (issue #226). + * + * Express keeps in-process counters (`totalQueries`, `totalUsdcSettled`, + * `avgLatencyMs`, `uptime`) and reports them. Vercel functions cannot: they are + * stateless, scale to zero, and each invocation may land on a fresh instance, + * so an in-memory counter there would report the current instance's lifetime + * rather than the deployment's activity. + * + * Before this contract existed, the serverless handler simply omitted those + * four fields and the UI coalesced the absent values to `0` / `'0.00'` — so a + * Vercel deployment rendered "0 queries, $0.00 settled, 0ms" beside a green + * "SERVER ONLINE" indicator. Those are not measurements; they are missing data + * presented as fact. + * + * So every runtime now *declares* what it measures: + * + * - Express → `statsSupported: true`, `unsupportedFields: []` + * - Vercel serverless → `statsSupported: false`, `unsupportedFields: [...]` + * plus a human-readable `statsUnavailableReason` + * + * Consumers (the browser StatsGrid and the MCP `get_search_stats` tool) call + * `resolveStat()` rather than reading the fields directly, so an unmeasured + * field can never be mistaken for a real zero. + * + * This contract covers reporting only. It does not touch the paid routes or + * their verified x402 settlement semantics. + */ + +/** The activity statistics a `/health` response may report. */ +export const MEASURED_STAT_FIELDS = [ + 'totalQueries', + 'totalUsdcSettled', + 'avgLatencyMs', + 'uptime', +] as const + +export type MeasuredStatField = (typeof MEASURED_STAT_FIELDS)[number] + +/** Why a stateless serverless deployment reports no activity counters. */ +export const SERVERLESS_STATS_UNAVAILABLE_REASON = + 'Serverless functions are stateless and scale to zero, so per-instance counters would reset on every cold start instead of reporting deployment activity. Run the Express server (npm run server) for live counters.' + +/** Shown when a deployment omits a field without declaring anything. */ +export const UNDECLARED_STAT_REASON = + 'This deployment did not report the metric and did not declare whether it measures it.' + +/** Shown when the health endpoint could not be reached at all. */ +export const SERVER_UNREACHABLE_REASON = 'The server health endpoint could not be reached.' + +/** + * The stats half of a `/health` payload: what this runtime measures, and — when + * it measures nothing — why. + */ +export interface HealthStatsDeclaration { + /** True when this runtime measures and reports every `MEASURED_STAT_FIELDS` entry. */ + statsSupported: boolean + /** Fields this runtime does not measure. Empty when `statsSupported` is true. */ + unsupportedFields: MeasuredStatField[] + /** Human-readable explanation. Present only when a field is unsupported. */ + statsUnavailableReason?: string +} + +/** The activity counters themselves, present only on a runtime that measures them. */ +export interface HealthStats { + totalQueries: number + /** Fixed-point USDC string, e.g. `"0.0040"`. */ + totalUsdcSettled: string + avgLatencyMs: number + /** Compact duration, e.g. `"42s"`, `"7m"`, `"3h"`. */ + uptime: string +} + +/** Configuration and settlement facts every runtime reports. */ +export interface HealthConfig { + status: 'ok' + network: string + pricePerQuery: string + protocol: 'x402' + facilitator: string + serperApiConfigured: boolean + groqApiConfigured: boolean + receivingAddressConfigured: boolean + timestamp?: string +} + +export type ServerHealthResponse = HealthConfig & HealthStatsDeclaration & Partial + +/** + * Declares that this runtime measures every statistic — used by Express, which + * holds counters in the process serving the paid routes. + * + * @returns The declaration to spread into a `/health` response. + */ +export function declareStatsSupported(): HealthStatsDeclaration { + return { statsSupported: true, unsupportedFields: [] } +} + +/** + * Declares that this runtime measures none of the activity statistics — used by + * the Vercel functions, which have nowhere durable to keep a counter. + * + * @param reason Why the statistics are unavailable here. + * @returns The declaration to spread into a `/health` response. + */ +export function declareStatsUnsupported(reason: string): HealthStatsDeclaration { + return { + statsSupported: false, + unsupportedFields: [...MEASURED_STAT_FIELDS], + statsUnavailableReason: reason, + } +} + +/** One statistic, resolved to either a real measurement or an explained absence. */ +export type StatResolution = + | { available: true; value: number | string } + | { available: false; reason: string } + +/** + * Reads one statistic from a health payload, keeping a genuine measurement + * distinct from an unmeasured field. + * + * A real `0` — a freshly started Express server that has served no queries — + * resolves as `{ available: true, value: 0 }`. A field the runtime does not + * measure resolves as `{ available: false, reason }`, never as zero. + * + * Tolerates a health payload from a deployment that predates this contract: + * present values are trusted, absent ones are reported as undeclared rather + * than assumed to be zero. + * + * @param health Parsed `/health` body, or `null` when the request failed. + * @param field Which statistic to read. + * @returns The resolved statistic. + */ +export function resolveStat(health: unknown, field: MeasuredStatField): StatResolution { + if (!health || typeof health !== 'object') { + return { available: false, reason: SERVER_UNREACHABLE_REASON } + } + + const payload = health as Partial & Record + + // An explicit declaration always wins, even if a stale value is also present. + const unsupported = Array.isArray(payload.unsupportedFields) && payload.unsupportedFields.includes(field) + if (payload.statsSupported === false || unsupported) { + return { + available: false, + reason: typeof payload.statsUnavailableReason === 'string' && payload.statsUnavailableReason.trim() !== '' + ? payload.statsUnavailableReason + : UNDECLARED_STAT_REASON, + } + } + + const value = payload[field] + if (typeof value === 'number' && Number.isFinite(value)) return { available: true, value } + if (typeof value === 'string' && value.trim() !== '') return { available: true, value } + + // Declared as supported but absent, or a pre-contract deployment: either way + // there is no measurement here, so do not invent one. + return { available: false, reason: UNDECLARED_STAT_REASON } +} + +/** + * True when the payload reports at least one real statistic, so a caller can + * label the whole panel rather than repeating a reason on every card. + * + * @param health Parsed `/health` body, or `null`. + * @returns Whether any measured statistic is available. + */ +export function hasAnyStats(health: unknown): boolean { + return MEASURED_STAT_FIELDS.some((field) => resolveStat(health, field).available) +} + +/** + * The single explanation to show when a reachable deployment reports no + * statistics at all. + * + * @param health Parsed `/health` body, or `null`. + * @returns The reason, or `null` when statistics are available. + */ +export function statsUnavailableReason(health: unknown): string | null { + if (hasAnyStats(health)) return null + const [first] = MEASURED_STAT_FIELDS + const resolved = resolveStat(health, first) + return resolved.available ? null : resolved.reason +} diff --git a/src/pages/DocsPage.test.tsx b/src/pages/DocsPage.test.tsx new file mode 100644 index 0000000..a0698c1 --- /dev/null +++ b/src/pages/DocsPage.test.tsx @@ -0,0 +1,200 @@ +/** + * src/pages/DocsPage.test.tsx + * + * Covers the public documentation of the paid image and news HTTP endpoints. + * + * The DocsPage is the in-app half of the API reference, so it must stay in + * step with the server contract: the `count` bounds and `freshness` enum it + * advertises are asserted against `src/lib/paramValidation.ts` (the same module + * the routes validate with), and the x402 challenge it shows is asserted + * against the constants the middleware pays out to. A drift in either place + * fails here rather than misleading an integrator. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import { DocsPage } from './DocsPage' +import { IMAGES_COUNT, NEWS_COUNT, SEARCH_COUNT, FRESHNESS_VALUES } from '../lib/paramValidation' +import { AMOUNT_USDC, AMOUNT_STROOPS, USDC_CONTRACT, STELLAR_NETWORK } from '../lib/constants' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), +})) +vi.mock('../i18n', () => ({ loadNamespace: vi.fn() })) +/** Animation props are dropped so React does not warn about unknown DOM attrs. */ +const MOTION_ONLY_PROPS = ['initial', 'animate', 'transition', 'exit', 'whileHover', 'whileTap'] + +vi.mock('framer-motion', () => ({ + motion: new Proxy({} as Record, { + get: (_target, tag: string) => + ({ children, ...props }: any) => { + const rest = Object.fromEntries(Object.entries(props).filter(([k]) => !MOTION_ONLY_PROPS.includes(k))) + return
{children}
+ }, + }), +})) + +/** Returns the endpoint card whose heading code block is `path`. */ +function endpointCard(path: string): HTMLElement { + const code = screen.getByText(path, { selector: 'code' }) + const card = code.closest('[data-motion="div"]') + if (!card) throw new Error(`No endpoint card found for ${path}`) + return card as HTMLElement +} + +describe('DocsPage — paid endpoint documentation', () => { + beforeEach(() => { + render() + }) + + it('documents all three paid endpoints', () => { + expect(screen.getByRole('heading', { name: /paid endpoints/i })).toBeInTheDocument() + for (const path of ['/search', '/images', '/news']) { + expect(endpointCard(path)).toBeTruthy() + } + }) + + it('shows the per-request price for each endpoint', () => { + for (const path of ['/search', '/images', '/news']) { + expect(within(endpointCard(path)).getByText(`${AMOUNT_USDC} USDC`)).toBeInTheDocument() + } + }) + + // ── Parameters and limits ────────────────────────────────────────────────── + + it('documents the count bounds that the server actually enforces', () => { + const cases: [string, typeof SEARCH_COUNT][] = [ + ['/search', SEARCH_COUNT], + ['/images', IMAGES_COUNT], + ['/news', NEWS_COUNT], + ] + for (const [path, bounds] of cases) { + const card = endpointCard(path) + expect(within(card).getByText('count')).toBeInTheDocument() + // e.g. "1–10 (default 10)" — the exact bounds from paramValidation.ts. + expect(within(card).getByText(`${bounds.min}–${bounds.max} (default ${bounds.default})`)).toBeInTheDocument() + } + }) + + it('documents the freshness enum for /search and /news', () => { + const expected = FRESHNESS_VALUES.join(' · ') + for (const path of ['/search', '/news']) { + expect(within(endpointCard(path)).getByText(expected)).toBeInTheDocument() + } + }) + + it('states that /images does not support freshness', () => { + expect(within(endpointCard('/images')).getByText('not supported')).toBeInTheDocument() + }) + + it('explains that invalid parameters return 400 before any payment challenge', () => { + const blurb = screen.getByText(/validated/i, { selector: 'p' }) + expect(blurb.textContent).toMatch(/before/i) + expect(blurb.textContent).toContain('400') + expect(blurb.textContent).toMatch(/never a 402/i) + }) + + // ── Result fields ────────────────────────────────────────────────────────── + + it('lists the ImageResult fields returned by /images', () => { + const fields = within(endpointCard('/images')).getByText(/imageUrl/) + for (const field of ['id', 'title', 'imageUrl', 'thumbnailUrl', 'sourceUrl', 'source', 'width', 'height']) { + expect(fields.textContent).toContain(field) + } + }) + + it('lists the NewsResult fields returned by /news', () => { + const fields = within(endpointCard('/news')).getByText(/snippet/) + for (const field of ['id', 'title', 'url', 'snippet', 'source', 'publishedAt', 'imageUrl']) { + expect(fields.textContent).toContain(field) + } + }) + + // ── curl examples ────────────────────────────────────────────────────────── + + it('gives a curl example per endpoint that URL-encodes the query safely', () => { + for (const path of ['/search', '/images', '/news']) { + const example = within(endpointCard(path)).getByText(/^curl/, { selector: 'code' }) + // `--data-urlencode` with `--get` is what keeps a spaced query correct. + expect(example.textContent).toContain('--get') + expect(example.textContent).toContain("--data-urlencode 'q=stellar lumens'") + expect(example.textContent).toContain(`http://localhost:3001${path}`) + } + }) + + it('uses the current x402 v2 payment header in every curl example', () => { + for (const path of ['/search', '/images', '/news']) { + const example = within(endpointCard(path)).getByText(/^curl/, { selector: 'code' }) + expect(example.textContent).toContain('PAYMENT-SIGNATURE:') + // The retired v1-only spelling must not reappear in the examples. + expect(example.textContent).not.toContain('X-Payment:') + } + }) + + it('passes freshness only on the endpoint that supports it', () => { + const news = within(endpointCard('/news')).getByText(/^curl/, { selector: 'code' }) + expect(news.textContent).toContain("--data-urlencode 'freshness=pw'") + + const images = within(endpointCard('/images')).getByText(/^curl/, { selector: 'code' }) + expect(images.textContent).not.toContain('freshness') + }) + + // ── Runtime boundaries ───────────────────────────────────────────────────── + + it('records which runtimes serve each endpoint, including the Vercel gap', () => { + expect(within(endpointCard('/search')).getByText(/Express · Vercel · MCP web_search/)).toBeInTheDocument() + expect(within(endpointCard('/images')).getByText(/no Vercel route/)).toBeInTheDocument() + expect(within(endpointCard('/news')).getByText(/no Vercel route/)).toBeInTheDocument() + }) + + // ── 402 challenge ────────────────────────────────────────────────────────── + + it('documents the 402 challenge and the headers that carry it', () => { + const heading = screen.getByRole('heading', { name: /the 402 challenge/i }) + const block = heading.parentElement as HTMLElement + + expect(block.textContent).toMatch(/402/) + expect(block.textContent).toMatch(/empty JSON body/i) + expect(block.textContent).toContain('PAYMENT-REQUIRED') + expect(block.textContent).toContain('Access-Control-Expose-Headers') + expect(block.textContent).toContain('PAYMENT-SIGNATURE') + expect(block.textContent).toContain('X-PAYMENT-RESPONSE') + expect(block.textContent).toContain('txHash') + }) + + it('shows a challenge payload matching the configured settlement constants', () => { + const heading = screen.getByRole('heading', { name: /the 402 challenge/i }) + const payload = within(heading.parentElement as HTMLElement).getByText(/x402Version/) + + expect(payload.textContent).toContain('"x402Version": 2') + expect(payload.textContent).toContain('"scheme": "exact"') + expect(payload.textContent).toContain(`"network": "${STELLAR_NETWORK}"`) + expect(payload.textContent).toContain(`"amount": "${AMOUNT_STROOPS}"`) + // Soroban contract address, never the "USDC:ISSUER" form. + expect(payload.textContent).toContain(`"asset": "${USDC_CONTRACT}"`) + expect(payload.textContent).not.toContain('USDC:') + }) + + it('warns that payment payloads are single-use', () => { + expect(screen.getByText(/Payment payload already consumed/)).toBeInTheDocument() + }) +}) + +describe('DocsPage — x402 payment flow steps', () => { + beforeEach(() => { + render() + }) + + it('names the header the 402 challenge actually travels on', () => { + const step = screen.getByRole('heading', { name: /server returns http 402/i }).closest('[data-motion="div"]') + expect(step?.textContent).toContain('PAYMENT-REQUIRED') + // The middleware emits PAYMENT-REQUIRED, not the X-Payment-Required spelling. + expect(step?.textContent).not.toContain('X-Payment-Required') + }) + + it('names PAYMENT-SIGNATURE as the v2 retry header and keeps X-PAYMENT as v1 compat', () => { + const step = screen.getByRole('heading', { name: /sign soroban auth entry/i }).closest('[data-motion="div"]') + expect(step?.textContent).toContain('PAYMENT-SIGNATURE') + expect(step?.textContent).toMatch(/X-PAYMENT is accepted for v1/i) + }) +}) diff --git a/src/pages/DocsPage.tsx b/src/pages/DocsPage.tsx index ca2c32e..80f5ac1 100644 --- a/src/pages/DocsPage.tsx +++ b/src/pages/DocsPage.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { motion } from 'framer-motion' import { useTranslation } from 'react-i18next' import { ExternalLink, GitBranch, Globe, Shield, Zap, Server } from 'lucide-react' -import { IS_MAINNET, STELLAR_NETWORK, AMOUNT_USDC, STELLAR_EXPERT_URL, HORIZON_URL } from '../lib/stellar' +import { IS_MAINNET, STELLAR_NETWORK, AMOUNT_USDC, AMOUNT_STROOPS, USDC_CONTRACT, STELLAR_EXPERT_URL, HORIZON_URL } from '../lib/stellar' import { loadNamespace } from '../i18n' const getSteps = () => [ @@ -15,20 +15,56 @@ const getSteps = () => [ { num: '02', icon: Zap, color: '#ffb800', title: 'Server returns HTTP 402', - desc: 'The @x402/express middleware responds with 402 Payment Required and a payment specification.', - code: `HTTP 402 · X-Payment-Required: {"amount":"10000","currency":"USDC","network":"${STELLAR_NETWORK}"}`, + desc: 'The @x402/express middleware responds with 402 and an empty JSON body — the payment specification travels base64-encoded in the PAYMENT-REQUIRED response header.', + code: `HTTP 402 · PAYMENT-REQUIRED: base64({"x402Version":2,"accepts":[{"amount":"${AMOUNT_STROOPS}","network":"${STELLAR_NETWORK}"}]})`, }, { num: '03', icon: Shield, color: '#7dd3fc', title: 'Sign Soroban auth entry', - desc: 'The x402 client signs a Soroban authorization entry via Freighter — no private key exposure.', - code: 'signAuthEntry(authEntry) → X-Payment: ', + desc: 'The x402 client signs a Soroban authorization entry via Freighter — no private key exposure. The signed payload goes back on PAYMENT-SIGNATURE (x402 v2); X-PAYMENT is accepted for v1 clients.', + code: 'signAuthEntry(authEntry) → PAYMENT-SIGNATURE: ', }, { num: '04', icon: Server, color: '#39ff14', title: 'Settle on Stellar + get results', desc: `OpenZeppelin facilitator verifies the signature, settles ${AMOUNT_USDC} USDC on-chain, and the server returns search results.`, - code: 'GET /search + X-Payment: → 200 OK + results', + code: 'GET /search + PAYMENT-SIGNATURE → 200 OK + results + X-PAYMENT-RESPONSE', + }, +] + +/** + * The paid HTTP endpoints, documented from the shared contract in + * `src/lib/paramValidation.ts` so the page cannot drift from the server: + * `count` bounds and the `freshness` enum are the same values the routes + * validate against. + */ +const getEndpoints = () => [ + { + method: 'GET', path: '/search', color: '#00f5ff', + summary: 'Organic web results, with optional AI-suggested follow-up queries.', + count: '1–20 (default 5)', + freshness: 'pd · pw · pm', + fields: 'id, title, url, description, source, relevanceScore, publishedAt?', + runtimes: 'Express · Vercel · MCP web_search', + example: `curl --get --data-urlencode 'q=stellar lumens' \\\n --data-urlencode 'count=5' \\\n -H "PAYMENT-SIGNATURE: $SIGNED_PAYLOAD" \\\n http://localhost:3001/search`, + }, + { + method: 'GET', path: '/images', color: '#ffb800', + summary: 'Image results from the Serper images API. No date filter — `freshness` is ignored.', + count: '1–10 (default 10)', + freshness: 'not supported', + fields: 'id, title, imageUrl, thumbnailUrl, sourceUrl, source, width?, height?', + runtimes: 'Express · MCP image_search (no Vercel route)', + example: `curl --get --data-urlencode 'q=stellar lumens' \\\n --data-urlencode 'count=10' \\\n -H "PAYMENT-SIGNATURE: $SIGNED_PAYLOAD" \\\n http://localhost:3001/images`, + }, + { + method: 'GET', path: '/news', color: '#39ff14', + summary: 'Recent articles from the Serper news API, optionally limited by age.', + count: '1–20 (default 10)', + freshness: 'pd · pw · pm', + fields: 'id, title, url, snippet, source, publishedAt?, imageUrl?', + runtimes: 'Express · MCP news_search (no Vercel route)', + example: `curl --get --data-urlencode 'q=stellar lumens' \\\n --data-urlencode 'count=10' --data-urlencode 'freshness=pw' \\\n -H "PAYMENT-SIGNATURE: $SIGNED_PAYLOAD" \\\n http://localhost:3001/news`, }, ] @@ -47,6 +83,7 @@ export function DocsPage() { const { t } = useTranslation('docs') const STEPS = getSteps() const STACK = getStack() + const ENDPOINTS = getEndpoints() const networkLabel = IS_MAINNET ? 'Mainnet' : 'Testnet' // `docs` is the one namespace loaded lazily rather than at app boot @@ -134,6 +171,122 @@ export function DocsPage() {
+ {/* Paid endpoints */} +
+
+ PAY-PER-QUERY HTTP API +

Paid endpoints

+

+ Three paid endpoints, each {AMOUNT_USDC} USDC ({AMOUNT_STROOPS} stroops) per request. All of them + share one contract: q is required (1–256 + characters), and count /{' '} + freshness are validated before any + payment challenge — an out-of-range or repeated value returns{' '} + 400, never a 402, so you are never charged for a request the + server was always going to refuse. +

+
+ +
+ {ENDPOINTS.map((ep, i) => ( + +
+ + {ep.method} + + {ep.path} + + {AMOUNT_USDC} USDC + +
+ +

{ep.summary}

+ +
+ {[ + ['count', ep.count], + ['freshness', ep.freshness], + ['result fields', ep.fields], + ['available on', ep.runtimes], + ].map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ +
+                
+                  {ep.example}
+                
+              
+
+ ))} +
+ + {/* 402 challenge */} +
+

The 402 challenge

+

+ An unpaid request returns 402 with an empty JSON body. + The challenge itself is base64-encoded in the{' '} + PAYMENT-REQUIRED response header, which is + listed in Access-Control-Expose-Headers so + browser clients can read it cross-origin. Decode it, sign{' '} + accepts[0], and retry with the payload on{' '} + PAYMENT-SIGNATURE. The settlement receipt + comes back on X-PAYMENT-RESPONSE and is + echoed into the body as txHash. +

+
+            
+{`{
+  "x402Version": 2,
+  "error": "Payment required",
+  "accepts": [{
+    "scheme": "exact",
+    "network": "${STELLAR_NETWORK}",
+    "amount": "${AMOUNT_STROOPS}",
+    "asset": "${USDC_CONTRACT}",
+    "payTo": "G...",
+    "maxTimeoutSeconds": 300
+  }]
+}`}
+            
+          
+

+ Each signed payload is single-use — replaying one inside its validity window returns 402{' '} + Payment payload already consumed. The{' '} + asset is always a Soroban C…{' '} + contract address, never USDC:ISSUER, and{' '} + amount is always in stroops. +

+
+
+ {/* Real stack */}
diff --git a/src/types/index.ts b/src/types/index.ts index fa256b7..9a470e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,15 @@ export type PaymentStep = 1 | 2 | 3 | 4 | 5 | 6 +export type { + HealthStats, + HealthStatsDeclaration, + HealthConfig, + ServerHealthResponse, + MeasuredStatField, + StatResolution, +} from '../lib/serverHealth' +import type { HealthStats, HealthStatsDeclaration } from '../lib/serverHealth' + export interface SearchResult { id: string title: string @@ -65,12 +75,15 @@ export interface SearchReceipt { network: string } -export interface ApiStat { - totalQueries: number - totalUsdcSettled: string - avgLatencyMs: number - uptime: string -} +/** + * The activity counters a `/health` response may carry. + * + * Every field is optional on the wire: only a runtime that actually measures + * them reports them, and it declares which ones it does not (#226). Read these + * through `resolveStat` in `src/lib/serverHealth.ts` so an unmeasured field is + * never mistaken for a real zero. + */ +export type ApiStat = Partial & HealthStatsDeclaration export interface ImageResult { id: string diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f84190a --- /dev/null +++ b/vercel.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "outputDirectory": "dist", + "functions": { + "api/**/*.ts": { + "maxDuration": 30 + } + }, + "rewrites": [ + { + "source": "/((?!api/).*)", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "/api/(.*)", + "headers": [ + { "key": "Access-Control-Allow-Origin", "value": "*" }, + { "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization, Idempotency-Key, X-Payment, x-payment, X-PAYMENT, payment-signature, PAYMENT-SIGNATURE" + }, + { + "key": "Access-Control-Expose-Headers", + "value": "PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-Payment-Response, X-Request-Id" + }, + { "key": "Cache-Control", "value": "no-store" } + ] + }, + { + "source": "/favicon.svg", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=86400" }] + } + ] +} diff --git a/vite.config.ts b/vite.config.ts index 5e661f0..12e46b0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,9 +37,11 @@ export default defineConfig({ 'src/lib/hashing.ts': { statements: 95, branches: 95, functions: 100, lines: 95 }, 'src/lib/serperNormalizer.ts': { statements: 95, branches: 90, functions: 100, lines: 95 }, 'src/lib/paramValidation.ts': { statements: 95, branches: 90, functions: 100, lines: 95 }, + 'src/lib/serverHealth.ts': { statements: 95, branches: 90, functions: 100, lines: 95 }, 'server/corsConfig.ts': { statements: 90, branches: 85, functions: 95, lines: 90 }, 'src/components/search/SearchBar.tsx': { statements: 80, branches: 80, functions: 90, lines: 80 }, 'src/components/search/SpellingCorrectionBanner.tsx': { statements: 85, branches: 90, functions: 70, lines: 85 }, + 'src/components/ui/StatsGrid.tsx': { statements: 90, branches: 90, functions: 100, lines: 95 }, 'src/pages/SearchPage.tsx': { statements: 65, branches: 65, functions: 70, lines: 75 }, 'server/index.ts': { statements: 30, branches: 24, functions: 25, lines: 35 }, 'api/search.ts': { statements: 90, branches: 75, functions: 80, lines: 90 },