API and cron health audit, 2026-05-19
Branch main. Read-only audit with surgical fixes. Scope: API endpoints inventory,
prod health probe, cron jobs, rate limits, observability, caching.
Summary
- 164 API routes across
src/app/api/**. - 45 cron entries in
vercel.json(Vercel Pro cron quota 100, OK). - 36 function-level
maxDuration/memoryoverrides (well under any limit). - All cron routes gated by
verifyCronSecret(Bearer orx-cron-secretheader). - Public health probe on
https://insiders-trades.com: 22 endpoints hit. 1 hard 500 found (/api/companies), now fixed. No other 5xx.
Top P0/P1 findings
| # | Sev | Endpoint | Finding | Status |
|---|---|---|---|---|
| 1 | P0 | GET /api/companies |
Unbounded prisma.company.findMany with _count: declarations returns 500 in 9.9 s on prod |
FIXED, pagination + cache + try/catch |
| 2 | P1 | POST /api/portfolio/import |
No rate limit, no row cap, did N upserts per request | FIXED, 10 imports / 10 min / IP, 1000 rows cap |
| 3 | P1 | GET /api/cron/refresh-backtest |
Missing export const dynamic = "force-dynamic" on a GET cron route |
FIXED |
| 4 | P1 | GET /api/cron/refresh-yahoo-fundamentals |
Same as above | FIXED |
| 5 | P1 | GET /api/cron/refresh-yahoo-news |
Same as above | FIXED |
| 6 | P2 | GET /api/v1/health |
2.3 s response, six freshness aggregates in sequence, no Cache-Control upstream | Not fixed, in-spec for uptime probe |
| 7 | P2 | GET /api/home-data |
4.5 s on cold edge despite unstable_cache(revalidate:60) |
Not fixed, expected after isolated cold start |
| 8 | P3 | Docs claim /api/insiders exists |
Only /api/v1/insiders exists |
Docs nit |
| 9 | P3 | Docs claim /api/feedback exists |
No such route | Docs nit |
| 10 | P3 | POST /api/portfolio/positions/reset |
Already had IP rate limit (portfolio-reset, 5 / 60 s) |
No change needed |
Endpoint matrix (public probe)
Status codes from curl -L against https://insiders-trades.com. Times in
seconds. cache is the Cache-Control of the final 200 response.
| Path | Status | Time | Cache-Control |
|---|---|---|---|
| /api/version | 200 | 0.28 | no-store, no-cache, must-revalidate |
| /api/v1/health | 200 | 2.32 | public, max-age=30 |
| /api/home-data | 200 | 4.48 | public |
| /api/companies | 500 | 9.94 | public, max-age=0, must-revalidate |
| /api/v1/companies | 401 | 0.38 | private, no-store (auth required) |
| /api/v1/insiders | 401 | 0.44 | private, no-store (auth required) |
| /api/v1/stats | 401 | 0.42 | private, no-store (auth required) |
| /api/v1/stats/summary | 200 | 1.00 | public, max-age=300 |
| /api/v1/markets | 401 | 0.38 | private, no-store (auth required) |
| /api/freshness | 200 | 0.36 | public, s-maxage=60, swr=120 |
| /api/openapi.json | 200 | 0.21 | public, max-age=300, s-maxage=300 |
| /api/docs | 200 | 0.49 | public, max-age=3600 |
| /api/markets/ticker | 200 | 1.03 | public |
| /api/live-ticker | 200 | 0.11 | public |
| /api/market-pulse | 200 | 0.12 | public |
| /api/recommendations | 200 | varies | public, s-maxage=600, swr=120 |
| /api/search | 200 | 0.40 | public |
| /api/sparkline/MC.PA | 200 | 0.07 | public |
| /api/declarations | 401 | 0.39 | (auth required) |
| /api/financials | 400 | 0.56 | (missing param expected) |
| /api/stock | 400 | 0.45 | (missing param expected) |
| /api/freshness/today-perf | 200 | 0.12 | public |
5xx count, after fixes: 0 expected. Before fixes: 1 (/api/companies).
Cron coverage
All 45 vercel.json crons entries map to a real route.ts. Auth: every cron
route imports verifyCronSecret and returns 401 on miss. Memory/maxDuration
overrides match expected workload (300 s + 1024 MB for waves and backfills,
30 s + 256 MB for sandbox/watchdog/refresh-sitemap).
Cron entries that point to non-/api/cron/ paths (kept for legacy reasons):
/api/sync-latest, /api/enrich-mcap, /api/backtest/compute,
/api/translate-news, /api/weekly-digest. All verified to call
verifyCronSecret.
Rate limit coverage (sensitive POST)
| Route | RL key | max / window |
|---|---|---|
| /api/auth/login | auth-login |
5 / 15 min |
| /api/auth/forgot-password | yes | tuned in route |
| /api/contact | contact |
3 / 60 min |
| /api/waitlist | waitlist |
5 / 10 min |
| /api/portfolio/positions/reset | portfolio-reset |
5 / 60 s |
| /api/portfolio/import | portfolio-import |
10 / 10 min (new) |
| /api/upgrade-request | yes | in route |
Observability
[security]prefix used in/api/auth/login(4 sites),/api/contact(4 sites),/api/waitlist, and now the two new portfolio limiters.[cron]prefix used inside/api/cron/sources-watchdogand a few siblings.- Other auth routes (
logout,magic,me,register,reset-password,verify) log viaconsole.erroronly. Acceptable. /api/versionreturnssha,branch,env,deployedAt. Used by deploy-guard SHA-match check./api/v1/healthreturns DB ping + freshness signals (no git SHA, that lives on/api/version).
Caching strategy vs doc claims
| Endpoint | Doc claim | Actual | Match? |
|---|---|---|---|
| /api/home-data | s-maxage=60, swr=300 |
s-maxage=60, swr=30 in code (header was scrubbed on edge to public) |
Close enough |
| /api/version | no-cache |
no-store, no-cache, must-revalidate |
yes |
| /api/v1/* (public) | s-maxage=300, swr=1800 |
s-maxage=300 only on stats/summary |
partial |
Fixes shipped
src/app/api/companies/route.ts— paginated GET (limit1..500,offset), try/catch,Cache-Control: public, s-maxage=60, stale-while-revalidate=300.src/app/api/portfolio/import/route.ts— IP rate limit (portfolio-import, 10 / 10 min),MAX_ROWS_PER_IMPORT=1000,export const dynamic = "force-dynamic".src/app/api/cron/refresh-backtest/route.ts— addedexport const dynamic = "force-dynamic".src/app/api/cron/refresh-yahoo-fundamentals/route.ts— same.src/app/api/cron/refresh-yahoo-news/route.ts— same.
Out of scope / deferred
/api/v1/healthlatency, candidate for sequence parallelisation later.- Docs in repo still reference non-existent
/api/feedbackand/api/insiders, cosmetic. - 308 trailing-slash dance adds one RTT on every API hit. Could be removed by consumers calling with the trailing slash directly.