From 7dc84f7178e1d2ed6633c03f2d9e1b8944f87fb5 Mon Sep 17 00:00:00 2001 From: nonso Date: Wed, 2 Sep 2026 14:20:46 +0100 Subject: [PATCH 1/5] fix(ci): resolve committed merge conflict in CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/workflows/ci.yml carried unresolved conflict markers committed to main in 7804e9c, which made the file invalid YAML. GitHub Actions could not parse it, so none of the CI jobs ran at all — including any job a branch protection rule might require. Resolve by keeping both sides: the real coverage-gate steps (npm ci plus threshold verification) replace the placeholder echo, and the supply-chain SBOM and vulnerability job is retained. --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) 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) From e8b47945cf194c2bd48774e3b35251c3a4779ec1 Mon Sep 17 00:00:00 2001 From: nonso Date: Wed, 2 Sep 2026 14:21:05 +0100 Subject: [PATCH 2/5] feat(api): document paid image and news HTTP endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and DocsPage covered /search only, while /images and /news were served by Express and proxied by the MCP tools with no public documentation of their parameters, limits, 402 challenge, paid response, or result fields. Document both in the README — per-route count bounds and freshness enums, an error table, the 402 challenge, paid response bodies, and ImageResult/NewsResult field tables — and in DocsPage, with curl examples that URL-encode queries via --data-urlencode rather than inlining them. Record explicitly that /images and /news are Express and MCP only, with no Vercel serverless equivalent. Documenting the limits required making them true. #188 added src/lib/paramValidation.ts and a full test matrix but never wired the contract into a route, so /images and /news silently clamped count (count=999 became 10) instead of rejecting it, and the README already described rejection that did not happen. Wire the shared contract in as middleware registered ahead of the x402 middleware, so an invalid count or freshness is refused with 400 without consulting the payment adapter or issuing a payment challenge — on Express and on Vercel /api/search alike. Verified against the running server: /images?q=x&count=999 now returns 400 "count must be between 1 and 10" rather than a 402. Also correct the payment headers shown in the DocsPage flow. The middleware emits the challenge on PAYMENT-REQUIRED, not X-Payment-Required, and takes the signed payload on PAYMENT-SIGNATURE, with X-PAYMENT retained for x402 v1 clients. --- README.md | 282 ++++++++++++++++++++++++++++++++++++ api/search.ts | 37 +++-- server/index.ts | 144 ++++++++++++------ src/pages/DocsPage.test.tsx | 200 +++++++++++++++++++++++++ src/pages/DocsPage.tsx | 165 ++++++++++++++++++++- 5 files changed, 770 insertions(+), 58 deletions(-) create mode 100644 src/pages/DocsPage.test.tsx diff --git a/README.md b/README.md index 1dab512..c9e04d4 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,288 @@ 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 ``` 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/server/index.ts b/server/index.ts index 1725765..a21ff2b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -32,6 +32,16 @@ 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 { formatConfigurationError, readServerConfig } from '../src/lib/config' import { normalizeOrganicResults, @@ -338,6 +348,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 +503,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 +512,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 +633,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 +642,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 +655,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 +709,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 +718,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 +796,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 +816,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 +896,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 +982,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 +1058,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' }, 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 */}
From eb7d6bf7438f02e25f73467a7c5d353084e6b8ca Mon Sep 17 00:00:00 2001 From: nonso Date: Wed, 2 Sep 2026 14:21:28 +0100 Subject: [PATCH 3/5] ci: smoke-test preview deployments before merge Pull requests validated nothing about an actual deployment. Serverless routing, CORS, environment wiring, static assets, and the SPA rewrite only exist once Vercel has built a preview, so unit tests cannot see them and a routing or CORS regression reached main unnoticed. Add scripts/smoke.mjs, a 12-check suite covering those, plus the shape of the x402 402 challenge: scheme exact, a stellar network, an integer stroop amount rather than a decimal dollar figure, a Soroban C... asset rather than USDC:ISSUER, and a G... payTo. That last check is the one a bad deploy silently breaks. The suite is non-secret by construction: no repository secrets, no Vercel token, no wallet, no signing material, and it never settles a payment, so a run costs 0 USDC. It is dependency-free plain ESM, so CI runs it straight from a checkout without npm ci. Add .github/workflows/preview-smoke.yml, triggered by the deployment_status event Vercel's GitHub integration emits, so no Vercel token is needed. Failures name the exact endpoint three ways: a ::error:: annotation per failed endpoint, a job-summary report carrying filtered headers and a truncated body, and an uploaded artifact. Add vercel.json, which did not exist, defining the serverless routing, SPA rewrites, and CORS headers the suite validates. 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. --- .github/workflows/preview-smoke.yml | 123 ++++++ README.md | 117 ++++++ scripts/smoke.mjs | 581 ++++++++++++++++++++++++++++ scripts/smoke.test.ts | 543 ++++++++++++++++++++++++++ vercel.json | 38 ++ 5 files changed, 1402 insertions(+) create mode 100644 .github/workflows/preview-smoke.yml create mode 100644 scripts/smoke.mjs create mode 100644 scripts/smoke.test.ts create mode 100644 vercel.json 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 c9e04d4..3355972 100644 --- a/README.md +++ b/README.md @@ -752,6 +752,123 @@ Coverage verifies the **x402 settlement semantics** for paid routes (`/search`, --- +## 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 | `GET /api/health` | `200`, `status: ok`, `protocol: x402` | missing `STELLAR_RECEIVING_ADDRESS` / `SERPER_API_KEY` | +| 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/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..dc2687a --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,581 @@ +#!/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 +} + +/** 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') + 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..f5fe4dd --- /dev/null +++ b/scripts/smoke.test.ts @@ -0,0 +1,543 @@ +/** + * 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, + 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, + }), + { 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') + }) +}) 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" }] + } + ] +} From 05dfe19869a472a467bf1e4e277c96dea5c77775 Mon Sep 17 00:00:00 2001 From: nonso Date: Wed, 2 Sep 2026 14:21:52 +0100 Subject: [PATCH 4/5] fix(health): make serverless health and statistics claims accurate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #226 The serverless health schema omitted totalQueries, totalUsdcSettled, avgLatencyMs and uptime while the browser coalesced them away (data.totalQueries ?? 0), so a Vercel deployment rendered "0 queries, $0.00 settled, 0ms" beside a pulsing green SERVER ONLINE indicator. None of that was a measurement; it was missing data presented as fact. The MCP get_search_stats tool fared worse — it called stats.totalQueries.toLocaleString() on the absent field, threw a TypeError, and surfaced it to the user as a misleading "Failed to fetch server stats". Durable serverless counters are not reachable without external storage, and an in-memory counter in a function that scales to zero would describe one warm instance rather than the deployment. So take the other option and declare the gap. src/lib/serverHealth.ts holds one contract shared by all four runtimes: Express declares statsSupported true, the Vercel handler declares the four fields unsupported with a reason, and omits them rather than reporting zeros. Consumers now read through resolveStat(), which keeps a measured zero distinct from an unmeasured field. StatsGrid renders unmeasured metrics as n/a — dimmed, without the pulsing dot that signals a live reading, with the reason on hover and in screen-reader text, and one panel-level note — while a freshly started Express server still shows a genuine 0, because having served no queries is a real measurement. MCP prints "not reported" plus a single reason line instead of throwing. The preview smoke suite now fails a deployment that omits the counters without declaring them, that claims support but omits the values, or that leaves a stale value on a field declared unsupported. Additive and backward compatible: no existing field changed type or meaning, and resolveStat tolerates deployments predating the contract by trusting present values and reporting absent ones as undeclared rather than as zero. Reporting only — the paid routes and their verified x402 settlement semantics are untouched. --- README.md | 127 ++++++++++++++- api/health.test.ts | 61 +++++++ api/health.ts | 18 ++- mcp-server/index.ts | 43 +++-- mcp-server/tools.test.ts | 106 ++++++++++++ scripts/smoke.mjs | 53 ++++++ scripts/smoke.test.ts | 92 +++++++++++ server/health.test.ts | 30 ++++ server/index.ts | 12 +- src/components/ui/StatsGrid.test.tsx | 234 +++++++++++++++++++++++++++ src/components/ui/StatsGrid.tsx | 209 +++++++++++++++--------- src/lib/serverHealth.test.ts | 200 +++++++++++++++++++++++ src/lib/serverHealth.ts | 187 +++++++++++++++++++++ src/types/index.ts | 25 ++- vite.config.ts | 2 + 15 files changed, 1300 insertions(+), 99 deletions(-) create mode 100644 src/components/ui/StatsGrid.test.tsx create mode 100644 src/lib/serverHealth.test.ts create mode 100644 src/lib/serverHealth.ts diff --git a/README.md b/README.md index 3355972..2bdce38 100644 --- a/README.md +++ b/README.md @@ -732,9 +732,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% | @@ -752,6 +754,129 @@ 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 @@ -771,7 +896,7 @@ before it can merge. | 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 | `GET /api/health` | `200`, `status: ok`, `protocol: x402` | missing `STELLAR_RECEIVING_ADDRESS` / `SERPER_API_KEY` | +| 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 | 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/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 index dc2687a..6695bfb 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -124,6 +124,58 @@ export function checkPaymentRequirements(challenge) { 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() @@ -215,6 +267,7 @@ export const CHECKS = [ 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 }, }, diff --git a/scripts/smoke.test.ts b/scripts/smoke.test.ts index f5fe4dd..2df2611 100644 --- a/scripts/smoke.test.ts +++ b/scripts/smoke.test.ts @@ -21,6 +21,7 @@ import { runSmoke, decodePaymentRequired, checkPaymentRequirements, + checkStatsDeclaration, normalizeBaseUrl, formatMarkdown, parseArgs, @@ -90,6 +91,10 @@ function deployment(overrides: Record> = {}) 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' } }, ) @@ -541,3 +546,90 @@ describe('runCheck', () => { 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 a21ff2b..7e2c39a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -42,6 +42,7 @@ import { 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, @@ -1128,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', @@ -1141,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/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/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 }, From f2932eda0b63e102108c90f680ad948f60eda12d Mon Sep 17 00:00:00 2001 From: nonso Date: Wed, 2 Sep 2026 14:22:04 +0100 Subject: [PATCH 5/5] docs: update README project tree to match the current codebase The documented tree predated the server helpers, serverless routes, UI components, tests and workflow files now in the repo, and listed components at paths they no longer occupy. Rebuild it from the actual file listing, and mark which code is shared versus runtime-specific: everything under src/lib is imported by the browser bundle, the Express server, the Vercel functions and the MCP server alike, so a change there has to keep all four aligned, while server/, api/ and mcp-server/ are each one runtime's implementation. Add a table mapping every test suite to the runtime it covers, so the Express, Vercel, browser and MCP boundaries are visible from the tree rather than inferred from filenames. --- README.md | 113 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2bdce38..289a5fd 100644 --- a/README.md +++ b/README.md @@ -505,42 +505,97 @@ and whether results were delivered — never the query text. See ## 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)