| @@ -1,92 +0,0 @@ | ||
| - | # Cyclops / Flywheel Security Assessment - Docket | |
| - | ||
| - | **Target:** http://localhost:3000 (pre-launch build) | |
| - | **Date:** 2026-06-26 | |
| - | **Scope:** Full web + `/api/*` assessment (authorized owner test) | |
| - | **Method:** White-box (source at `/shared/projects/docket`) + live black-box PoC via Flywheel MCP scanner and raw HTTP. Every finding below was verified against the running target - no assertion without a request/response. | |
| - | **Stack:** Next.js 14.2.28 (app router), better-sqlite3, read-only public data viewer, no auth, no writes, no uploads. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 1. Findings Table | |
| - | ||
| - | | # | Severity | Finding | Endpoint / File | Status | | |
| - | |---|----------|---------|-----------------|--------| | |
| - | | 1 | **Low** | Rate-limit bypass via spoofable client-IP headers | `middleware.ts` `clientIp()` | REAL (deployment-dependent) | | |
| - | | 2 | **Low** | Availability/DoS under concurrency - synchronous full-table loads on every API call, single event loop | `lib/feed.ts`, `lib/db.ts`, all `/api/*` (`force-dynamic`) | REAL (deployment-dependent) | | |
| - | | 3 | **Info** | CSP allows `script-src 'unsafe-inline'` (no nonce) - weakens XSS defense-in-depth | `next.config.js` | REAL (no exploitable sink today) | | |
| - | | 4 | **Info** | `q` reflected verbatim in `/api/search` JSON body | `lib/feed.ts` `globalSearch` | REAL but NOT XSS | | |
| - | | 5 | **Info** | `X-XSS-Protection` header absent | `next.config.js` | Non-issue (deprecated; CSP supersedes) | | |
| - | | - | - | "Reflected XSS in q" (scanner) | `/api/search` | **FALSE POSITIVE** | | |
| - | | - | - | "File path in error response" (scanner) | `/api/feed/<bogus>` | **FALSE POSITIVE** | | |
| - | ||
| - | **Not present (verified negative):** SQL injection, IDOR/auth bypass, SSRF (params + headers), CORS misconfig, open redirect, CRLF/header injection, OS command injection, SSTI, path traversal, HTTP verb tampering, dangerous HTTP methods, debug/admin endpoints, `.env`/`.git`/DB-file/source-map exposure, stack-trace/info disclosure. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 2. Real Findings - detail + PoC | |
| - | ||
| - | ### #1 Rate-limit bypass via spoofable client-IP headers - Low | |
| - | The limiter (120 req/min/IP, fixed window) derives the client identity from request headers in this order: `CF-Connecting-IP` → `X-Real-IP` → `X-Forwarded-For` → `"unknown"`, with **no trust validation**. When the origin is reached directly (not through the CDN), an attacker rotates any of these headers to get a fresh 120-request budget per value - effectively unlimited. | |
| - | ||
| - | Code (`middleware.ts`): | |
| - | ``` | |
| - | const cf = req.headers.get("cf-connecting-ip"); if (cf) return cf.trim(); | |
| - | const real = req.headers.get("x-real-ip"); if (real) return real.trim(); | |
| - | const xff = req.headers.get("x-forwarded-for"); if (xff) return xff.split(",")[0].trim(); | |
| - | ``` | |
| - | PoC: a flat burst with a unique `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` per request never hit 429, while the limiter does fire for a single un-spoofed identity (a saturating same-identity burst produced a clean `429` with `Retry-After`/`X-RateLimit-*`). Each distinct header value maps to its own bucket. | |
| - | ||
| - | This is **by design** for a CDN deployment (the code comments state L3/L4 protection lives at the edge). It is only a real weakness if the origin is publicly reachable without the CDN in front. | |
| - | ||
| - | **Fix:** Trust `CF-Connecting-IP` only when the peer socket is a known Cloudflare IP; otherwise fall back to the real socket address, not attacker-controlled headers. Firewall the origin to CDN IPs. | |
| - | ||
| - | ### #2 Availability under concurrency - Low | |
| - | Every `/api/*` route is `export const dynamic = "force-dynamic"` (no caching) and the data layer (`getFeedItems`/`getBills`/`getMembers`/`globalSearch`) loads whole tables into memory and filters/sorts in JS via synchronous `better-sqlite3` calls, which block the single Node event loop. | |
| - | ||
| - | PoC: a single `/api/feed` call = `time_total 0.42s`, 25 KB. A 300-request concurrent burst (50 parallel) saturated the server: **282/300 connections failed/timed out ("000"), 18 succeeded**, and the post-burst request returned `429`. Combined with #1 (header-rotating rate-limit bypass), a distributed flood against a directly-reachable origin can degrade availability. | |
| - | ||
| - | **Fix:** Cache the read API at the CDN edge (these are public, slow-changing records); add `Cache-Control`/`s-maxage` to API responses; push table aggregation into SQL (`WHERE`/`LIMIT`) instead of loading full tables per request. Lock the origin behind the CDN. | |
| - | ||
| - | ### #3 CSP `'unsafe-inline'` scripts - Info | |
| - | `script-src 'self' 'unsafe-inline'` (required by Next.js inline hydration without a nonce pipeline). No reflected/stored/DOM HTML-injection sink exists today (React auto-escapes; the only `dangerouslySetInnerHTML` is the static theme bootstrap in `app/layout.tsx`), so this is defense-in-depth only. Consider a nonce-based CSP later. | |
| - | ||
| - | ### #4 `q` reflected in `/api/search` - Info, NOT XSS | |
| - | `/api/search?q=<script>alert(1)</script>` returns `{"q":"<script>alert(1)</script>", ...}` with `Content-Type: application/json` and `X-Content-Type-Options: nosniff`. Browsers do not execute scripts in a JSON response, and `nosniff` blocks content-type confusion. Not exploitable. The search **page** renders results through React (escaped). No action required; optionally drop the echo. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 3. False Positives (scanner heuristics) | |
| - | - **"Reflected XSS in q"** (`test_xss`, conf 0.8): payload reflected, but response is `application/json` + `nosniff` → no execution. See #4. | |
| - | - **"File path in error response"** (`test_info_disclosure`, conf 0.7): the 404 is the standard Next.js HTML 404 page; the heuristic matched the request path text. No filesystem path, no stack trace. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 4. What was tested and held (PoCs run) | |
| - | - **SQLi:** `?q=' OR '1'='1`, `?state=CA' OR '1'='1`, and a time-based `from=ih' AND 1=randomblob(1e8)--` on `/api/bills/1/diff` → payloads stored/compared as literals (12ms vs 29ms baseline, no delay). All queries use named bound params (`:id`, `:from`, `:to`, `:st`, `:q`); filtering is done in JS post-fetch; `LIKE` escapes `% _`; `safeCount` whitelists table names to `[a-z_]+`. **No injection.** | |
| - | - **Pagination abuse:** `limit=999999999` clamped to `MAX_LIMIT=200`; `limit=-1&offset=-5` falls back to defaults. **No DoS via limit.** | |
| - | - **Path traversal / id abuse:** `/api/bills/..%2f..%2fetc%2fpasswd` → 404; oversized id → 404; ids `parseInt`-normalized before binding. | |
| - | - **Static exposure:** `/docket.db`, `/opt/docket/data/docket.db`, `/.env`, `/.git/config`, `/next.config.js`, `*.js.map` → all **404**. | |
| - | - **CORS:** no `Access-Control-Allow-Origin` reflection. **Open redirect / SSRF (params + headers):** no URL-typed params, no outbound fetch from user input - nothing to test. **CRLF, verb tampering, dangerous methods, debug endpoints:** none. | |
| - | - **Security headers (live):** CSP, HSTS (2y, preload), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, COOP, CORP all present; `poweredByHeader` off (no `X-Powered-By`/version leak). | |
| - | - **IDOR:** N/A - no accounts, no per-user data; all data is public read-only by design. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 5. Must-fix before public launch | |
| - | These are **deployment** controls, not code vulnerabilities: | |
| - | 1. **Put the origin behind the CDN and firewall it to CDN IPs only.** This neutralizes #1 and #2. Do not expose `:3000` publicly. | |
| - | 2. **Make `CF-Connecting-IP` trust conditional** on a Cloudflare peer IP (so the header limiter isn't trivially bypassable even if the origin is ever reachable). | |
| - | 3. **Cache the read API at the edge** (`s-maxage`) and/or move filtering into SQL - reduces per-request cost and removes the concurrency-saturation foothold. | |
| - | ||
| - | Nice-to-have (not blocking): nonce-based CSP (#3), drop the `q` echo (#4). | |
| - | ||
| - | --- | |
| - | ||
| - | ## 6. Verdict | |
| - | ||
| - | ### GREEN for public launch - *behind a CDN with the origin locked to CDN IPs.* | |
| - | ||
| - | No critical, high, or medium code-level vulnerabilities were found. SQLi, XSS, IDOR, SSRF, traversal, CORS, redirect, CRLF, cmdi, SSTI, and info-disclosure are all absent or non-exploitable. The SQL layer is uniformly parameterized, output is React-escaped, security headers are strong, and pagination/limits are clamped. | |
| - | ||
| - | The only real findings (rate-limit header bypass, concurrency saturation) are **deployment-dependent** and fully mitigated by the planned CDN front + origin IP lockdown. If the origin were exposed directly to the internet without the CDN, downgrade to **NOT-GREEN** until items 1-2 in §5 are in place. |
| @@ -1,172 +0,0 @@ | ||
| - | # Docket - Navigation Latency Audit & Fix Notes | |
| - | ||
| - | Session: 2026-06-27. All timings local (`curl -w "%{time_total}"` against | |
| - | `http://localhost:3000` on the server). Cloudflare Tunnel overhead is on top of these. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Root Causes (investigated) | |
| - | ||
| - | ### 1. No Cache-Control on any API route (CONFIRMED, FIXED) | |
| - | ||
| - | Every `/api/*` GET response had zero `Cache-Control` header before this session. | |
| - | Cloudflare's default for headerless JSON responses is "bypass cache" - meaning | |
| - | every client-side API call (register-hero 15-second poll, MembersBrowser | |
| - | "show historical" paginated fetches, MemberVoteYear lazy year panels) made a | |
| - | full round-trip over the CF Tunnel to the the server origin. | |
| - | ||
| - | Fix: added `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` | |
| - | to all `/api/*` routes via `next.config.js` headers(). Routes that already set | |
| - | their own Cache-Control header in the response body (members, member-votes) | |
| - | override this cleanly with the same value. | |
| - | ||
| - | ### 2. `force-dynamic` still on pure static API routes | |
| - | ||
| - | `/api/categories` and `/api/states` read no search params - they returned | |
| - | the same data every time - but carried `force-dynamic`, preventing Next.js | |
| - | server-side ISR. Changed to `export const revalidate = 300` so the Node | |
| - | server caches the response object for 5 min in addition to CF edge caching. | |
| - | ||
| - | ### 3. No `loading.tsx` on any heavy page (CONFIRMED, FIXED) | |
| - | ||
| - | Without `loading.tsx`, clicking a nav link caused a noticeable blank/stall | |
| - | while the RSC payload loaded over the tunnel. The header stayed in place but | |
| - | the content area showed nothing. | |
| - | ||
| - | Fix: added `loading.tsx` skeletons for the six heaviest routes - register, | |
| - | votes, bills, spending, members, calendar. They render immediately on | |
| - | client-side navigation while the RSC payload is in-flight. | |
| - | ||
| - | ### 4. Pages already on ISR - not a root cause here | |
| - | ||
| - | All pages (`/register`, `/votes`, `/bills`, etc.) already carried | |
| - | `export const revalidate = 300` and return `Cache-Control: s-maxage=300, | |
| - | stale-while-revalidate`. This was fixed in the previous perf session. | |
| - | The `x-nextjs-cache: STALE/HIT` headers confirm ISR is working correctly. | |
| - | ||
| - | ### 5. Plain `<a href>` links - not present in nav | |
| - | ||
| - | All internal navigation uses `<Link>` from `next/link`. No regressions here; | |
| - | Next.js client-side transitions + prefetch are already wired correctly. | |
| - | ||
| - | ### 6. Font loading - already correct | |
| - | ||
| - | Four fonts (Archivo, JetBrains Mono, Fraunces, Atkinson Hyperlegible) all | |
| - | loaded with `display: "swap"` via `next/font/google`. No blocking font fetch. | |
| - | ||
| - | ### 7. Page payload sizes (gzip) - acceptable after compression | |
| - | ||
| - | Raw HTML sizes appear large but gzip brings them to reasonable transfer sizes: | |
| - | - `/register`: 1.45 MB raw → 92 KB gzip | |
| - | - `/votes`: 958 KB raw → 96 KB gzip | |
| - | - `/spending`: 2.85 MB raw → 206 KB gzip (320 awards + fiscal cards + apportionments) | |
| - | ||
| - | --- | |
| - | ||
| - | ## Files Changed | |
| - | ||
| - | | File | Change | | |
| - | |------|--------| | |
| - | | `next.config.js` | Added `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` header rule for `/api/:path*` | | |
| - | | `app/api/categories/route.ts` | `force-dynamic` → `revalidate = 300` | | |
| - | | `app/api/states/route.ts` | `force-dynamic` → `revalidate = 300` | | |
| - | | `app/api/feed/route.ts` | Removed duplicate handler-level Cache-Control (config default now applies) | | |
| - | | `app/api/members/route.ts` | Explicit `Cache-Control` in response (same as config default; documents intent) | | |
| - | | `app/api/members/[id]/votes/route.ts` | Explicit `Cache-Control` in response | | |
| - | | `app/register/loading.tsx` | New - hero + table skeleton | | |
| - | | `app/votes/loading.tsx` | New - filter bar + table skeleton | | |
| - | | `app/bills/loading.tsx` | New - filter bar + table skeleton | | |
| - | | `app/spending/loading.tsx` | New - fiscal cards + table skeleton | | |
| - | | `app/members/loading.tsx` | New - member card grid skeleton | | |
| - | | `app/calendar/loading.tsx` | New - calendar grid skeleton | | |
| - | ||
| - | QA after rebuild: **CLEAN** - 17 routes, no overflow, no hydration errors. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Before/After Timings (local, the server localhost) | |
| - | ||
| - | These are origin times (no Cloudflare). The "win" from Cloudflare edge caching | |
| - | shows up for end-users, not on localhost. | |
| - | ||
| - | | Route | Before (this session) | After | Note | | |
| - | |-------|-----------------------|-------|------| | |
| - | | `/register` | 0.19s | 0.19s | ISR warm; unchanged | | |
| - | | `/votes` | 0.31s | 0.31s | ISR warm; unchanged | | |
| - | | `/bills` | 0.25s | 0.25s | ISR warm; unchanged | | |
| - | | `/members` | 0.11s | 0.11s | ISR warm; unchanged | | |
| - | | `/calendar` | 0.31s | 0.06s | Second-hit ISR | | |
| - | | `/spending` | 0.66s | 0.74s | 320 awards still full-table; within reason | | |
| - | | `/api/votes` | - (no CC) | CC: s-maxage=300 | CF now caches | | |
| - | | `/api/feed` | - (no CC) | CC: s-maxage=300 | CF now caches | | |
| - | | `/api/members` | - (no CC) | CC: s-maxage=300 | CF now caches | | |
| - | | `/api/categories` | - (force-dynamic, no CC) | CC: s-maxage=300 (ISR) | CF + Node cache | | |
| - | ||
| - | --- | |
| - | ||
| - | ## Cloudflare Cache Rule Required (Overseer action) | |
| - | ||
| - | Cloudflare by default **bypasses cache for HTML responses** even when the | |
| - | origin sends `Cache-Control: s-maxage=300`. Pages in this app return ISR HTML | |
| - | with `s-maxage=300, stale-while-revalidate` - but CF won't cache HTML without | |
| - | an explicit cache rule. | |
| - | ||
| - | Create this rule in the Cloudflare dashboard (or via CF API) for the `usdocket.org` zone: | |
| - | ||
| - | **Rule:** Cache Everything - match `usdocket.org/*` | |
| - | - Cache status: `Cache Everything` | |
| - | - Edge TTL: `Respect existing headers` (honors the `s-maxage=300` from origin) | |
| - | - Browser TTL: `Respect existing headers` | |
| - | ||
| - | This causes the CF edge to cache the full HTML responses for 5 minutes and | |
| - | serve repeat requests without hitting the tunnel. Combined with the ISR | |
| - | s-maxage already set, navigations from new users to already-cached pages will | |
| - | return from CF's edge POP in ~5ms instead of traversing the tunnel. | |
| - | ||
| - | For API routes: no separate rule needed. JSON responses with | |
| - | `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` will be | |
| - | cached by CF automatically once "Cache Everything" is active (CF's default | |
| - | "Cache Only User Content" rule skips JSON by content-type, but "Cache | |
| - | Everything" overrides that). | |
| - | ||
| - | **CF API call to create the rule (requires CF Zone ID + API token):** | |
| - | ||
| - | ```bash | |
| - | # Zone ID and API token are in /shared/projects/.env.secrets | |
| - | ZONE_ID="<cf_zone_id>" | |
| - | CF_TOKEN="<cf_api_token>" | |
| - | ||
| - | curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets" \ | |
| - | -H "Authorization: Bearer $CF_TOKEN" \ | |
| - | -H "Content-Type: application/json" \ | |
| - | -d '{ | |
| - | "name": "Cache Everything - usdocket.org", | |
| - | "kind": "zone", | |
| - | "phase": "http_request_cache_settings", | |
| - | "rules": [ | |
| - | { | |
| - | "action": "set_cache_settings", | |
| - | "action_parameters": { | |
| - | "cache": true, | |
| - | "edge_ttl": { | |
| - | "mode": "respect_origin_ttl" | |
| - | }, | |
| - | "browser_ttl": { | |
| - | "mode": "respect_origin" | |
| - | } | |
| - | }, | |
| - | "expression": "true", | |
| - | "description": "Cache all public Docket content respecting origin TTL" | |
| - | } | |
| - | ] | |
| - | }' | |
| - | ``` | |
| - | ||
| - | --- | |
| - | ||
| - | ## Summary for the Overseer | |
| - | ||
| - | Code changes are deployed on the server at `/opt/docket`. QA passes. | |
| - | The single remaining action for the overseer is the Cloudflare cache rule above, | |
| - | which unlocks edge caching of HTML pages and makes the existing `s-maxage=300` | |
| - | headers actually take effect for end-users. |
| @@ -1,128 +0,0 @@ | ||
| - | # Docket - Performance Pass 2: Edge-Cacheable Filter Pages | |
| - | ||
| - | Session 2026-06-27. Performance-only; no visual/design changes. | |
| - | ||
| - | ## Problem | |
| - | ||
| - | The interactive list pages (`/votes`, `/bills`, `/members`, `/spending`, | |
| - | `/executive`, `/search`) read URL `searchParams` inside their **server** | |
| - | components. In the Next.js App Router, touching `searchParams` opts the route | |
| - | into **dynamic rendering**, so Next emits: | |
| - | ||
| - | ``` | |
| - | Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate | |
| - | ``` | |
| - | ||
| - | Cloudflare honors that and returns `cf-cache-status: BYPASS`. Every visit then | |
| - | crossed the home Cloudflare Tunnel to the the server origin (~0.5-0.8s) instead of | |
| - | serving from the CF edge (~50-150ms). The `/api/*` routes were already | |
| - | cacheable (`public, s-maxage=300`). | |
| - | ||
| - | ## Fix - static shell + client-side filtering | |
| - | ||
| - | Each filter page was split into: | |
| - | ||
| - | 1. A **static server shell** (`export const revalidate = 300`, no | |
| - | `searchParams` access) that renders the page header + cite block. | |
| - | 2. A **client browser component** (`"use client"`) that reads the URL with | |
| - | `useSearchParams()`, fetches the matching CF-cached `/api/*` route, and | |
| - | renders the filtered/sorted results. A loading skeleton (`animate-pulse`) | |
| - | shows during the fetch. | |
| - | ||
| - | Because the shell no longer reads `searchParams`, Next renders it statically | |
| - | and CF caches the HTML. The filtered data comes from the `/api/*` routes, which | |
| - | are themselves CF-cached per query string - so filtered views are fast too. | |
| - | ||
| - | ### Pages converted to static-shell + client-filter | |
| - | ||
| - | | Page | Client component | API it calls | | |
| - | |---|---|---| | |
| - | | `/votes` | `components/votes-browser.tsx` | `/api/votes?chamber=&q=&result=&sort=` | | |
| - | | `/bills` | `components/bills-browser.tsx` | `/api/bills?policyArea=&q=&congress=&sort=` | | |
| - | | `/spending` (awards section only) | `components/spending-awards-browser.tsx` | `/api/spending/awards?q=&sort=` | | |
| - | | `/search` | `components/search-browser.tsx` | `/api/search?q=` (type/sort applied client-side) | | |
| - | ||
| - | `/members` and `/executive` never read `searchParams` (they already filter | |
| - | client-side in `members-browser`/`executive-browser`), so they only needed the | |
| - | cache header (below). The `/spending` fiscal-totals and apportionments sections | |
| - | stay server-rendered (no `searchParams`). | |
| - | ||
| - | ### API routes extended to accept the page filters | |
| - | ||
| - | - `app/api/votes/route.ts` - now parses `q`, `result` (→ `resultFilter`), | |
| - | `sort` (was chamber-only); delegates to `getVotesFiltered` / | |
| - | `getVoteCountFiltered`. | |
| - | - `app/api/bills/route.ts` - sort mapping fixed to handle all four values | |
| - | (`updated|action|congress|status`). | |
| - | - `app/api/spending/awards/route.ts` - added `sort` (`date|agency`, default | |
| - | largest-amount-first) via `getAwardsFiltered(q, sort)`. | |
| - | ||
| - | All keep their existing `public, s-maxage=300, stale-while-revalidate=600` | |
| - | header (from `next.config.js`). | |
| - | ||
| - | ### next.config.js cache rule | |
| - | ||
| - | Added a page-route `Cache-Control` rule so CF reliably caches the HTML shells | |
| - | and the ISR detail pages, even if Next would otherwise emit a dynamic header: | |
| - | ||
| - | ```js | |
| - | { | |
| - | source: "/(votes|bills|bill|members|spending|executive|search|states)(.*)?", | |
| - | headers: [{ key: "Cache-Control", value: "public, s-maxage=300, stale-while-revalidate=600" }], | |
| - | } | |
| - | ``` | |
| - | ||
| - | Note the **singular `bill`**: detail routes are `/bill/[id]` (singular) while | |
| - | `/votes/[id]`, `/members/[id]`, `/states/[code]` are plural. `bill` was | |
| - | initially missing, so `/bill/[id]` alone fell through to Next's dynamic | |
| - | `no-store` default and CF bypassed it. Adding `bill` fixed it. | |
| - | ||
| - | ## Verification | |
| - | ||
| - | Each route hit twice through `https://usdocket.org`; 2nd-hit result reported. | |
| - | ||
| - | ### Before (confirmed by owner / pre-fix) | |
| - | `/votes` and the other filter pages returned `cf-cache-status: BYPASS` with | |
| - | `Cache-Control: private, no-store` - every hit crossed the tunnel. | |
| - | ||
| - | ### After | |
| - | ||
| - | | Route | cf-cache-status (2nd hit) | time_total | | |
| - | |---|---|---| | |
| - | | `/votes` | HIT | ~0.14s | | |
| - | | `/bills` | HIT | ~0.13s | | |
| - | | `/members` | HIT | ~0.25s | | |
| - | | `/spending` | HIT | ~0.20s | | |
| - | | `/executive` | HIT | ~0.29s | | |
| - | | `/search` | HIT | ~0.14s | | |
| - | | `/votes?chamber=house` | HIT | ~0.13s | | |
| - | | `/votes?chamber=senate` | HIT | - | | |
| - | | `/bills?policyArea=Taxation` | HIT | ~0.13s | | |
| - | | `/bills?sort=congress` | HIT | - | | |
| - | | `/spending?sort=date` | HIT | - | | |
| - | | `/votes/[id]` (detail) | HIT | ~0.14s | | |
| - | | `/bill/[id]` (detail) | HIT | ~0.16s | | |
| - | ||
| - | All routes serve `Cache-Control: public, s-maxage=300, stale-while-revalidate=600`. | |
| - | Filtered query-string variants each become their own CF cache key and are HIT | |
| - | on repeat. No route emits `private`/`no-store` anymore. | |
| - | ||
| - | ### Filter correctness (sampled against the live API) | |
| - | ||
| - | - `?chamber=senate` → all returned votes are SENATE (total 12,916). | |
| - | - `?result=passed` → all rows have yea > nay (414>2, 52>45, 50>44). | |
| - | - bills `?sort=congress` → 119th Congress rows first. | |
| - | - awards `?sort=agency` → alphabetical by agency (Agency for Intl Development, | |
| - | Dept of Agriculture, ...). | |
| - | ||
| - | ## Build / QA | |
| - | ||
| - | `bash /shared/projects/docket/_ops/rebuild-qa.sh` → | |
| - | **QA CLEAN - no overflow, no hydration errors across 17 routes.** | |
| - | ||
| - | ## Net effect | |
| - | ||
| - | Repeat navigations to every list and detail page now serve from the Cloudflare | |
| - | edge (`HIT`, ~130-290ms) instead of crossing the tunnel to origin. The earlier | |
| - | the page is warmed, the more consistently sub-150ms it is. Rate-limit | |
| - | middleware (`/api/:path*` only) and the security headers are unchanged. |