89 - API v1 audit (2026-05-20)
Audit of every route under src/app/api/v1/*. Scope:
- Auth gate (none / api-key / honeypot).
- Rate-limit headers.
- Cache + Vary headers.
- Error envelope shape.
- Public-readable vs strategy-IP exposure.
Output:
- Public
/api/v1/strategy/proofadded (sanitizedSTRATEGY_PROOF, CORS open). - OpenAPI 3.1 spec extended at
/api/openapi.json. - Human-readable docs already at
/docs/(unchanged, references new endpoint indirectly via OpenAPI tag).
Route matrix
| Route | Method | Auth | Rate-limit hdr | Cache | Notes |
|---|---|---|---|---|---|
/api/v1/admin/seed |
GET/POST | HONEYPOT | n/a | no-store |
Blacklists caller 30d. Always 200 with bait payload. |
/api/v1/backtest |
GET | api-key | yes | force-dynamic | Backtest stats. |
/api/v1/clusters |
GET | api-key | yes (apiJson) |
AGGREGATE_CACHE |
Cluster discovery. |
/api/v1/companies |
GET | api-key | yes | revalidate=60 | Listing. |
/api/v1/companies/[slug] |
GET | api-key | partial | force-dynamic | Detail. Returns 404 if missing. |
/api/v1/companies/[slug]/declarations |
GET | api-key | partial | force-dynamic | Per-issuer declarations. |
/api/v1/companies/[slug]/insider-flow |
GET | api-key | yes (apiJson) |
revalidate=60 | Aggregated flow. |
/api/v1/declarations |
GET | api-key | yes (apiJson) |
LISTING_CACHE |
Main listing. Supports ?currency=, ?since=. |
/api/v1/declarations/[amfId] |
GET | api-key | partial | force-dynamic | Declaration detail (includes backtest). |
/api/v1/export |
GET | api-key | tier-aware | mixed (302 redirect or CSV stream) | Tier-gated archive download. QUANT=blob redirect, PRO=CSV stream, FREE=sample. |
/api/v1/health |
GET | PUBLIC | n/a | revalidate=0 | Status pages. 503 if DB down. |
/api/v1/insiders |
GET | api-key | partial | LISTING_CACHE |
Listing. |
/api/v1/insiders/[slug] |
GET | api-key | partial | force-dynamic | Detail. |
/api/v1/insiders/[slug]/declarations |
GET | api-key | partial | LISTING_CACHE |
Per-insider declarations. |
/api/v1/insiders/[slug]/timeline |
GET | api-key | yes (apiJson) |
force-dynamic | Cumulative position timeline. |
/api/v1/internal/dump |
GET/POST | HONEYPOT | n/a | no-store |
Bait. Blacklists caller 30d. |
/api/v1/leaderboard/companies |
GET | api-key | yes (apiJson) |
revalidate=300 | Net-flow ranking. |
/api/v1/leaderboard/insiders |
GET | api-key | yes (apiJson) |
revalidate=300 | Insider activity ranking. Anti-placeholder filter applied. |
/api/v1/markets |
GET | api-key | yes (apiJson) |
revalidate=300 | Regulator catalogue. |
/api/v1/me |
GET | api-key | partial | revalidate=0 | Key + user echo. PII (email). |
/api/v1/oos-performance |
GET | PUBLIC | n/a | unstable_cache 1h | OOS tracker. Honesty endpoint. |
/api/v1/recommendations |
GET | api-key | yes (apiJson) |
LISTING_CACHE |
Mode-driven picks. PRO/QUANT inline backtest. |
/api/v1/sandbox/[...path] |
GET | fingerprint | sandbox hdrs | upstream | Anonymous proxy. Captcha/POW on calls 7-10. |
/api/v1/sandbox/challenge |
POST | PUBLIC | n/a | no-store |
Issues POW challenge. |
/api/v1/sandbox/status |
GET | PUBLIC | n/a | force-dynamic | Quota inspection, no consumption. |
/api/v1/score/distribution |
GET | api-key | yes (apiJson) |
revalidate=300 | Histogram + percentiles. |
/api/v1/scoring/explain/[amfId] |
GET | api-key | yes (apiJson) |
force-dynamic | 9-factor breakdown. |
/api/v1/search |
GET | api-key | partial | force-dynamic | Fuzzy entity search. |
/api/v1/sectors |
GET | api-key | yes (apiJson) |
revalidate=600 | Sector rotation. |
/api/v1/signals |
GET | api-key | partial | LISTING_CACHE |
Top-scored declarations. |
/api/v1/stats |
GET | api-key | partial | revalidate=60 | Detailed counters. |
/api/v1/stats/summary |
GET | PUBLIC | n/a | revalidate=300 | Surface counters for social-proof widgets. |
/api/v1/strategy/proof (NEW) |
GET | PUBLIC | n/a | s-maxage=900 | Sanitized STRATEGY_PROOF (no filter values). CORS open. |
/api/v1/strategy/winning |
GET | api-key | partial | revalidate=900 | Live signal stream + filter criteria. Strategy IP. |
Notes on the "partial" rate-limit-header column: routes built on withMeta instead
of apiJson do not emit X-RateLimit-* headers today. They still enforce the quota
server-side via bumpKeyUsage (fire-and-forget), but the client cannot tell from
the response alone how many calls it has left. Tracked as follow-up; not blocking.
Findings
IP exposure - /api/v1/strategy/winning
The endpoint returns both the live signal stream and WINNING_STRATEGY (the
six filter values that define the strategy). Behind a paid key today, which is
correct. We deliberately surface the audit-grade proof (yearly returns, win
rate, three Sharpes, biases) on /performance and /methodologie already - so
adding a sanitized public mirror of the same proof block at
/api/v1/strategy/proof lets external auditors and embedders consume the
honesty numbers without us re-exposing the IP. Done in this audit.
PII - /api/v1/me
Returns the caller's own email, first/last name, role. Required for the endpoint's contract (key + user echo). No leakage to third parties since the key authenticates the caller. OK.
Honeypots
admin/seed and internal/dump return plausible 200s and blacklist the
caller's fingerprint for 30 days. Working as intended; not touched.
Sandbox proxy
/api/v1/sandbox/[...path] already enforces fingerprint quota, datacenter ASN
deny, POW for bot UAs, hCaptcha on calls 7-10, IP-prefix bucket caps, route
allowlist. No changes.
Error envelopes
Every route that uses errorJson (@/lib/api-auth) returns the RFC-7807-ish
shape { error: { code, message, status } }. Routes that throw raw exceptions
let Next.js render a generic 500 with no stack leakage (production build
strips). No SQL error strings observed in any handler. OK.
Cache + Vary headers
Auth-gated routes use force-dynamic or short revalidate windows. The proxy
(src/proxy.ts) sets Cache-Control: no-store on every 401 it emits and
applies Vary: Cookie to public pages. API routes do not currently set
Vary: Authorization. Low risk because every authed route is
force-dynamic (no cache layer between key holders), but cleaner to add. Not
fixed in this pass to preserve behavior parity; tracked.
Standardization candidates (follow-up, NOT applied to keep diff small)
- Migrate routes still on
withMeta(...)toapiJson(...)so they emitX-RateLimit-*headers automatically. Affected:/me,/stats,/companies/[slug],/companies/[slug]/declarations,/declarations/[amfId],/insiders/[slug],/insiders/[slug]/declarations,/signals,/search,/strategy/winning. - Add
Vary: Authorizationon every authed JSON response. - Centralize the CORS preflight handler shared by
proofand the sandbox routes into@/lib/api-auth.
None of the above is required for security; they are hygiene items.
Acceptance
npm run lint:emdashandnpm run lint:emojiboth green over the new files.npx tsc --noEmitclean.- New endpoint reachable:
curl -sS http://localhost:3000/api/v1/strategy/proof | jq .strategy.name. - OpenAPI spec lists the new operation with
security: []. - Existing routes untouched; auth-gated contracts preserved.
Files
src/app/api/v1/strategy/proof/route.ts(new)src/lib/openapi-spec.ts(path added)docs/method-review/89-api-v1-audit-2026-05-20.md(this file)