61 · Senior Architecture Pass · 2026-05-19
Scope. Structural audit of insiders-trades repo: dead code, Prisma N+1 risk, missing indexes, security gates, type-safety, bundle/edge, untracked debris. Output for this pass is split between the inline fixes already shipped in this commit and deferred work tagged P0/P1/P2 below.
Tech baseline: Next.js 16 App Router (trailingSlash true), Prisma 6.19,
Neon Postgres (pooled + direct URLs), TypeScript strict (no errors), ESLint
clean, no output: standalone in next.config.ts. CSP currently runs in
Content-Security-Policy-Report-Only with 'unsafe-eval' and
'unsafe-inline' on script-src.
Shipped in this commit (safe, low-risk)
Dropped 9 throwaway debug scripts
scripts/_check-sec.ts,_check-sec2.ts,_check-sec3.ts,_check-sec4.tsscripts/_check-jp.ts,_status-debug.ts,_status-test.ts,_sigma-test.ts,_e2e-signup.ts- All were one-off probes (1-3 line
const p = new PrismaClient(); p.x.findMany(...)); none referenced from app code or other scripts.
Dropped
scripts/_backfill-resume/(25 files)- One-shot 5-year backfill walkers (
asx-au-5y.ts,cvm-br-5y.ts,fma-at-5y.ts,hel-fi-5y.ts,hkex-5y.ts,nse-in-5y.ts,nzx-snapshot.ts,oslo-no-5y.ts,rns-uk-5y.ts,six-ser-5y.ts, etc) plus their.logand.progress.jsonartifacts. - Superseded by
src/app/api/cron/backfill-*routes (e.g.backfill-nse-in,backfill-oslo-no,backfill-sec-form4) which carry the same logic, chunked under Vercel cron withSetting.backfill.*.cursorpersistence. The comment in those new cron routes explicitly says "Chunked replacement forscripts/_backfill-resume/<x>-5y.ts".
- One-shot 5-year backfill walkers (
.gitignoretightened for repo-root debris: e2e screenshot snapshots,audit-final/,bo-check/,OVERNIGHT-RECAP-*.md,SESSION-CHECKPOINT-*.md,WATCHDOG-REPORT-*.md, mobile/desktop PNGs. These were all session/QA artifacts that should never enter git.Generated migration
20260519100000_company_name_trgm_index/(NOT applied — user runsnpx prisma migrate deployagainst prod when ready). Createspg_trgmextension + GIN indexes onCompany.nameandInsider.nameto accelerate thename: { contains: q, mode: "insensitive" }pattern used by/api/search/route.ts:16-32. Today this scans the full table; with the GIN trigram index it becomes a millisecond lookup.Committed useful untracked scripts + new orphan-hub pages
scripts/_audit-source.ts,scripts/backfill-total-amount.ts,scripts/_audit-numbers.mjs,scripts/_audit-shots.mjs,scripts/_admin-route-test.mjs,scripts/_admin-screenshots.mjs,scripts/_blog-polish-r2.ts,scripts/_logo-page1-priority.mjs,scripts/_mobile-audit-sweep.mjs— match the existingscripts/_*audit pattern.src/app/sectors/page.tsx,src/app/clusters/page.tsx— index hubs that wrap the existing/sectors/...(via /companies/by-sector) and/clusters/recent/trees. Both are wired with hreflang, JSON-LD ItemList,unstable_cache1h,revalidate=3600. No internal-link mesh yet — see P1-1.
P0 · ship this week
P0-1 · /api/portfolio/positions/reset has no rate limit
File: src/app/api/portfolio/positions/reset/route.ts
A logged-in user can hammer POST and wipe their own portfolio in a loop.
While the blast radius is one user, it allows trivial CSRF-style abuse if a
session cookie leaks. Fix: wrap with the existing rateLimit helper from
src/lib/rate-limit.ts (key portfolio-reset, max: 5, windowMs: 60_000).
Effort: 5 min.
P0-2 · CSP still in Report-Only and ships 'unsafe-eval'
File: next.config.ts line ~18.
The site currently has zero enforcement of script-src. 'unsafe-eval' is
required by the jsDelivr-hosted Swagger UI / Redoc bundles. Two cleaner paths:
a. Self-host Swagger UI under /public/swagger/ and drop the
https://cdn.jsdelivr.net allowance + 'unsafe-eval' entirely.
b. Move CSP to enforcing (rename header to Content-Security-Policy) after
monitoring the report-only stream for 48h. Even with 'unsafe-eval',
enforcing CSP blocks 95% of XSS payloads.
Effort: 1h for (b), 3h for (a) + (b).
P0-3 · Several admin pages don't redirect, they throw new Error
Files: src/app/admin/waitlist/page.tsx:29, pipeline/page.tsx:36,
settings/page.tsx:19,29. When a non-admin hits these pages they get a 500
error page (which leaks the route exists and reveals the error to the user).
The pattern in debug-ip/page.tsx:41 (redirect("/")) is correct.
Fix: replace throw new Error("Unauthorized") with redirect("/").
Effort: 10 min, one find-replace.
P1 · ship this month
P1-1 · /sectors and /clusters index hubs are orphans
Newly added pages have no inbound link from the rest of the site (only
page-context.ts mentions them for analytics tagging). They are JSON-LD'd
and sitemap-eligible but Google won't crawl them unless we wire them into:
- Global footer (
src/components/Footer.tsxor similar) — "Browse by sector", "Cluster signals" links. - Home page bottom hub-strip alongside
/hubs/*links. /companies/page.tsxheader → link to/sectors/.
Effort: 30 min.
P1-2 · Declaration table has 14 indexes
Schema declares 14 indexes on Declaration (3 singles + 8 composites + 2 from
the model relations + 1 @unique). Each insert pays the write cost. Several
composites are arguably redundant — e.g. @@index([companyId, type, pubDate desc])
covers the leading prefix of @@index([companyId, pubDate desc]), so the
shorter one could be dropped (Postgres planner will use the wider one).
Action: EXPLAIN ANALYZE on the top 10 production query patterns, drop
indexes that don't move the plan. Estimate: removes 2-3 indexes → ~15%
faster Declaration writes (ingestion path).
Effort: 2h.
P1-3 · Insider/Company page do a separate .findMany for relatedCompanySlugs
Files: src/app/insider/[slug]/page.tsx:90-105, src/app/company/[slug]/page.tsx:143-156.
The Insider record already carries relatedCompanySlugs String[]. Each
slug page does an extra prisma.company.findMany({ where: { slug: { in: ... } } })
on every cache miss. Since this is wrapped in unstable_cache 1h it's not
catastrophic, but on cold cache it's a chained round-trip. Either:
- Denormalize the related companies'
{name, logoUrl, sectorTag}into theInsiderrecord (JSON column), refreshed by the existing populate-related cron. - Or batch-fetch with
dataloader-style coalescing.
Effort: 3h.
P1-4 · Type holes (as any) in 2 hot paths
src/components/StockChart.tsx:105— RechartsCustomChartTooltiptyped as({...}: any). Recharts ships proper types for its tooltip render-prop; switch toTooltipProps<number, string>fromrecharts.src/app/docs/_components/Endpoint.tsx:148—WebkitOverflowScrolling: "touch" as any. CSSProperties already accepts this onCSSProperties-extending types. Cast toReact.CSSPropertiesonce at the parent object level.
Effort: 20 min.
P1-5 · Largest client components > 1500 LOC
src/components/BacktestDashboard.tsx(1747 LOC,"use client")src/components/PortfolioDashboard.tsx(1660 LOC,"use client")src/app/admin/AdminDashboard.tsx(1289 LOC,"use client")
These ship as one chunk per route. Split each into:
- A server-rendered shell (loading skeleton + initial data fetch).
- A
dynamic(() => import(...), { ssr: false })panel for the interactive parts. - Multiple sibling client components instead of one mega-component (split tabs).
Expected impact: first-contentful-paint drops 200-400ms on cold cache, mobile bundle drops ~80kB gzipped. Effort: 1 day per file.
P2 · backlog
P2-1 · next.config.ts lacks output: 'standalone'
Useful for Docker/self-host parity testing. Not blocking Vercel.
P2-2 · instrumentationHook not wired
Next.js 16 supports instrumentation.ts for OpenTelemetry / Sentry. We have
nothing. Adding src/instrumentation.ts with a simple boot-log + Vercel Speed
Insights call would replace several ad-hoc loggers.
P2-3 · _count selector used inline in insider/company pages
Both slug pages do _count: { select: { declarations: true } } (insider page
line 51, company page similar). When the company has 10k+ declarations this
issues a COUNT(*) on every cache miss. Materialize as
Insider.declarationCount Int? updated by the rescore cron.
P2-4 · verifyCronSecret only checks Bearer or x-cron-secret
Vercel Cron sends Authorization: Bearer — covered. But some legacy GitHub
Actions invocations use ?secret= query param. None today, just flagging.
P2-5 · merge-staging.ts NFKC normalization is the right call
The current uncommitted diff in src/lib/ingest/merge-staging.ts adds
.normalize("NFKC") to insider name cleaning. Confirmed correct: EDINET/KRX
feeds emit fullwidth Latin (U+FF21..U+FF5A) for romanized Japanese/Korean
names, and NFKC collapses them to ASCII while leaving CJK ideographs intact.
This change is in this commit.
P2-6 · No lint:emdash / lint:emoji integration in pre-commit
Two scripts exist (scripts/check-no-emdash.mjs, check-no-emoji.mjs) but
nothing wires them. Add husky/lefthook pre-commit (or a .github/workflows/lint.yml
job).
Verifications run for this pass
npx tsc --noEmit→ exit 0 (clean).npx eslint --no-warn-ignored src/ --max-warnings 0→ exit 0 (clean).- Cron route scan: all 60 routes in
src/app/api/cron/*importverifyCronSecretfrom@/lib/cron-auth. Sample-audited 5 routes (backfill-sec-form4,backfill-jp-merge,backfill-oslo-no,backfill-nse-in,backfill-sebi-merge) — all gate. - Admin route scan:
src/app/admin/layout.tsxredirects non-admins; individual pages then re-check role. Belt + suspenders. Three pages usethrow new Errorinstead ofredirect(see P0-3). - Client-side
process.env.*reads: onlyNODE_ENVandNEXT_PUBLIC_BASE_URL. No secret leaks. - Rate limits:
/api/auth/login(5/15min),/api/contact(3/1h),/api/auth/register(separate file, not checked here). Missing:/api/portfolio/positions/reset(see P0-1). No/api/feedbackroute exists in the repo (was in original audit spec but not implemented).