diff --git a/CLAUDE.md b/CLAUDE.md
index 7c3fb41..c3251bb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,25 +4,24 @@
Research dashboard tracking AI infrastructure investment — data centers, compute, power/energy, public equities. Live at gridtilt.com. Built by Jack Schwartz (aurph).
## Tech Stack
-React 18 + TypeScript + Vite + Tailwind + shadcn/ui frontend. Node.js + Express 5 backend. Drizzle ORM, optional Postgres. Deployed on Replit.
+React 18 + TypeScript + Vite + Tailwind + shadcn/ui frontend. Node.js + Express 5 backend. Persistence is curated JSON in `server/data/` (no database). Deployed on Replit (autoscale; the filesystem is ephemeral across deploys).
## Key Directories
- client/src/pages/ — Route page components
- client/src/components/ — Shared components (app-sidebar.tsx, NewsTicker.tsx, EmailCapture.tsx)
- client/src/data/ — Config files (supply-chain-config.ts, catalyst-config.ts)
-- server/ — Express API, routes.ts, seo.ts, storage.ts
-- shared/ — Shared types and schemas
+- server/ — Express API: routes.ts, indices.ts, seo.ts, and data/ (curated JSON datasets)
## Conventions
- Dark mode only (warm charcoal + orange theme, #F07800 brand color)
- JetBrains Mono for branding, Inter for body
- shadcn/ui components in client/src/components/ui/
- No co-authored-by lines in commits
-- Keyboard shortcuts: G+1 through G+8 for navigation
+- Keyboard shortcuts: G+1 through G+9 for navigation
## Deployment
Push to GitHub -> Pull on Replit -> Replit auto-deploys
**Gotcha:** Replit autoscale/reserved-VM does NOT auto-pull on push. Manual redeploy required via Replit → Deployments → Redeploy. Don't say "shipped" after a push.
-## Active redesign
-The homepage (`/`) is being redesigned. Read `HOMEPAGE_HANDOFF.md` before touching anything under `client/src/pages/TiltOverview.tsx` or `client/src/App.tsx` routes. Anchor is Swiss; route split moves dashboard to `/overview`.
+## Homepage (shipped)
+The `/` → `/overview` route split shipped in May 2026: `/` is the marketing home (`client/src/pages/Home.tsx`), `/overview` is the dashboard (`TiltOverview.tsx`). The Swiss anchor described in `HOMEPAGE_HANDOFF.md` was tried and then removed in favor of GridTilt's own dark brand; treat that handoff as historical.
diff --git a/HANDOFF.md b/HANDOFF.md
index f3d8eb3..7d1c26d 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,5 +1,7 @@
# GridTilt — Handoff for Claude / Replit / future-you
+> **HISTORICAL — superseded (2026-06-09).** Kept for provenance. The "ACTIVE BLOCKER" below was resolved weeks ago: production is current, and the `/`→`/overview` route split, the LBNL interconnection queue (item #13), index-history, and live gauges all shipped. Do not act on the open items here without checking `docs/audit/` first.
+
Last updated: 2026-05-21
Repo: `aurph/GridTilt` (main)
Owner: Jack Schwartz / aurph
diff --git a/HOMEPAGE_HANDOFF.md b/HOMEPAGE_HANDOFF.md
index 4360552..44b2301 100644
--- a/HOMEPAGE_HANDOFF.md
+++ b/HOMEPAGE_HANDOFF.md
@@ -1,5 +1,7 @@
# GridTilt Homepage Redesign — Handoff for a Fresh Claude Code Session
+> **HISTORICAL — the redesign shipped, then changed direction (2026-06-09).** The `/`→`/overview` route split is live. The Swiss anchor described here was implemented and then deliberately torn out in favor of GridTilt's own dark brand; the "Build your own" waitlist was dropped. This plan no longer matches the shipped homepage — read `client/src/pages/Home.tsx` for current reality.
+
Created: 2026-05-21
Owner: Jack Schwartz / aurph
Branch to create: `redesign/home-swiss`
diff --git a/README.md b/README.md
index 35fa24b..0ce7872 100644
--- a/README.md
+++ b/README.md
@@ -13,9 +13,9 @@ Live: **[gridtilt.com](https://gridtilt.com)**
| Module | What's inside |
|---|---|
| **Tilt Overview** | Top movers, sector pulse, catalyst calendar, US electricity demand chart (2010 → 2030 projection), thesis-health KPIs. |
-| **The Stack** | 60+ tickers across 8 supply-chain layers (compute, nuclear, uranium, power hardware, utilities, construction, hyperscalers, REITs). |
+| **The Stack** | 100 tickers across 13 supply-chain layers (compute, nuclear, uranium, power hardware, utilities, data-center REITs, construction, mining, natural gas, renewables, grid hardware, crypto/AI DC, ETF benchmarks). |
| **Power Map** | US data center locations with power capacity and the utility / RTO they sit on. |
-| **Supply Chain** | D3 force graph of 21 nodes and 44 real supply relationships from raw materials to end-use compute. |
+| **Supply Chain** | D3 force graph of 24 nodes and 52 real supply relationships from raw materials to end-use compute. |
| **Catalyst Tracker** | Earnings + thesis catalyst calendar across 80+ tickers. |
| **Portfolio Overlay** | Score any portfolio 0–100 on AI-power exposure across 5 dimensions. |
| **Thesis Calculator** | Sliders for demand growth, nuclear capacity, and grid stress to model scenarios. |
@@ -55,7 +55,7 @@ Baselines (72, 68) and clamps are presentation choices that keep the gauges read
## Stack
- **Frontend** — React 18, TypeScript, Vite, Tailwind, shadcn/ui, Recharts, D3, React Leaflet
-- **Backend** — Node 20, Express 5, Drizzle ORM, optional Postgres (in-memory fallback)
+- **Backend** — Node 20, Express 5; persistence is curated JSON in `server/data/` (no database)
- **Data** — `yahoo-finance2`, `rss-parser`, curated JSON datasets in `server/data/`
- **Hosting** — Replit Deployments (autoscale)
@@ -88,7 +88,6 @@ npm run check # typecheck
| `RESEND_API_KEY` | no | Syncs subscribers to Resend and enables newsletter sends. Without it, signups only persist to local JSON (ephemeral on autoscale hosts). |
| `EIA_API_KEY` | no | Free key from [eia.gov/opendata](https://www.eia.gov/opendata/register.php). Enables live US48 hourly demand at `/api/physical/load-hourly`. |
| `NEWSDATA_API_KEY` | no | Optional [newsdata.io](https://newsdata.io) key. The 8 RSS feeds work without it. |
-| `DATABASE_URL` | no | Postgres connection string. Without it, the app uses in-memory storage. |
| `X_API_*` | no | Four X credentials for the daily auto-poster; dry-run logs locally without them. |
---
@@ -103,8 +102,8 @@ client/ React + Vite frontend
data/ Static config (supply chain graph, ticker metadata)
server/ Express API
routes.ts All HTTP endpoints + RSS / Yahoo fetchers
- storage.ts IStorage interface (in-memory + Postgres impls)
-shared/ Types and Drizzle schemas shared between client and server
+ indices.ts Pure index math (unit-tested)
+ data/ Curated JSON datasets (subscribers, queue, datacenters, ...)
```
---
diff --git a/SECURITY.md b/SECURITY.md
index de1247e..70413c9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -3,7 +3,7 @@
This file documents what's in place to protect gridtilt.com and its
subscribers. If you find a vulnerability, see "Reporting" at the bottom.
-Last updated: 2026-05-23
+Last updated: 2026-06-09
---
@@ -31,15 +31,16 @@ Configured in `server/index.ts`:
- **Content-Security-Policy**
- `default-src 'self'`
- - `script-src 'self' https://platform.twitter.com`
- (production; development adds `'unsafe-eval' 'unsafe-inline'` for Vite HMR)
+ - `script-src 'self'`
+ (production; development adds `'unsafe-eval' 'unsafe-inline'` for Vite HMR.
+ The old Twitter widget origin was removed when X retired timeline embeds.)
- `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`
(React inline-style props render as `style="..."` attributes; without
`'unsafe-inline'` for styles the entire component tree breaks)
- `font-src 'self' https://fonts.gstatic.com data:`
- `img-src 'self' data: https:`
- `connect-src 'self'` (production; development adds `ws: wss:`)
- - `frame-src 'self' https://platform.twitter.com`
+ - `frame-src 'self'`
- `frame-ancestors 'none'` (no embedding of GridTilt anywhere)
- `object-src 'none'`
- `base-uri 'self'`
@@ -48,7 +49,10 @@ Configured in `server/index.ts`:
- **X-Frame-Options**: `SAMEORIGIN`
- **X-Content-Type-Options**: `nosniff`
- **Referrer-Policy**: `strict-origin-when-cross-origin`
-- **Permissions-Policy**: `camera=() microphone=() geolocation=()`
+- **Permissions-Policy**: not currently emitted. helmet 8 does not set this
+ header by default and the code does not add it. Sending
+ `camera=() microphone=() geolocation=()` is a cheap, recommended hardening
+ step tracked in `docs/audit/01_findings.md` (SEC-7).
- **X-XSS-Protection**: `0` (explicit disable; modern browsers' built-in
XSS auditor is deprecated and can be tricked into worsening security)
- `X-Powered-By` header is **disabled** so the stack isn't advertised.
diff --git a/client/src/pages/PowerMap.tsx b/client/src/pages/PowerMap.tsx
index 5f9145d..7432b37 100644
--- a/client/src/pages/PowerMap.tsx
+++ b/client/src/pages/PowerMap.tsx
@@ -118,6 +118,18 @@ function pushFiltersToURL(companies: string[], rtos: string[], capacity: string)
type ViewMode = "dc" | "stress";
+// Escape user-controlled strings before interpolating them into raw Leaflet
+// divIcon HTML. Datacenter names come from the admin form / ingester pipeline,
+// so an unescaped name is a stored-XSS vector (SEC-5).
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
function pinRadius(powerMW: number): number {
if (powerMW >= 500) return 10;
if (powerMW >= 100) return 8;
@@ -329,7 +341,7 @@ function FacilityLabels({ viewMode, filterCompanies, filterRTOs, filterCapacity,
const truncName = dc.name.length > 20 ? dc.name.slice(0, 18) + ".." : dc.name;
const label = L.marker([dc.lat, dc.lng], {
icon: L.divIcon({
- html: `
${truncName}
`,
+ html: `${escapeHtml(truncName)}
`,
className: "leaflet-label-icon",
iconSize: [120, 16],
iconAnchor: [-8, 20],
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..2b4019e
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,138 @@
+# GridTilt — Architecture
+
+> Generated during the Phase 0 audit (2026-06-09). Reflects `main` @ `dbbf7ac`, verified against a live read of the code and a running local instance. This is the map; problems found while drawing it live in `docs/audit/01_findings.md`.
+
+## What it is
+
+A single Node/Express process that serves both a React SPA and a JSON API on one port (5000). No database: all persistence is JSON files under `server/data/` read and written with `fs` from inside `server/routes.ts`. External data comes from Yahoo Finance (unofficial), 8 RSS feeds, FRED, and EIA. Email goes through Resend; social posts through the X v2 API with hand-rolled OAuth 1.0a. Deployed on Replit autoscale, where the filesystem is ephemeral across deploys and scale events.
+
+```
+Browser ──HTTP──> Express (one process, port 5000)
+ ├── /api/* JSON API (server/routes.ts, ~60 endpoints)
+ ├── /sitemap.xml, /feed.xml, /robots.txt, /api/og, ... (SEO)
+ └── /* SPA (prod: dist/public + SEO meta injection;
+ dev: Vite middleware)
+```
+
+## Request → data flow
+
+```mermaid
+flowchart LR
+ subgraph Client[React SPA · Wouter routes · TanStack Query]
+ H["/ Home (lazy)"]
+ OV["/overview TiltOverview (lazy)"]
+ ST["/stack TheStack"]
+ PM["/power-map PowerMap · Leaflet"]
+ SC["/supply-chain SupplyChain · D3 force"]
+ Qп["/queue Queue"]
+ CT["/catalysts CatalystTracker"]
+ TR["/trade TheTrade"]
+ PO["/portfolio PortfolioOverlay"]
+ end
+
+ subgraph API[Express · server/routes.ts]
+ KPI[/api/kpis/]
+ STK[/api/stack/]
+ SCP[/api/supply-chain/]
+ CAT[/api/catalysts/all/]
+ EAR[/api/earnings-calendar/]
+ DCN[/api/datacenters/]
+ QUE[/api/queue/]
+ PHY[/api/physical/*/]
+ NEWS[/api/news/]
+ SUB[/api/subscribe/]
+ OG[/api/og/]
+ end
+
+ subgraph Caches[In-process module state · lost on redeploy]
+ SCACHE[stackCache by timeframe · 10m]
+ ECACHE[earningsCache · 4h]
+ NCACHE[newsCache · 1h]
+ FCACHE[fredCache · 24h]
+ end
+
+ subgraph Ext[External]
+ YF[Yahoo Finance]
+ RSS[8 RSS feeds]
+ FRED[FRED IPG2211A2N]
+ EIA[EIA US48]
+ RESEND[Resend]
+ X[X / Twitter]
+ end
+
+ subgraph Files[server/data/*.json · ephemeral on autoscale]
+ F_SUBS[subscribers.json]
+ F_HIST[index-history.json]
+ F_Q[interconnection-queue.json]
+ F_DC[datacenters.json]
+ F_DCP[datacenters-pending.json]
+ F_MC[market-constants.json]
+ F_SOC[social-log.json]
+ F_STAGE[supply-chain-stages.json]
+ end
+
+ H --> KPI
+ OV --> KPI & EAR & CAT
+ ST --> STK
+ PM --> DCN
+ SC --> SCP
+ Qп --> QUE
+ CT --> CAT
+ TR --> KPI
+ PO -->|POST| API
+
+ KPI --> YF
+ KPI -->|append| F_HIST
+ STK --> SCACHE --> YF
+ SCP --> SCACHE
+ SCP --> F_STAGE
+ CAT --> ECACHE --> YF
+ EAR --> ECACHE
+ DCN --> F_DC
+ QUE --> F_Q
+ PHY --> FCACHE --> FRED
+ PHY --> EIA
+ NEWS --> NCACHE --> RSS
+ NEWS -->|scanners write| F_Q & F_MC
+ SUB -->|write| F_SUBS
+ SUB --> RESEND
+ OG --> YF
+```
+
+## Persistence — what is written at runtime (the autoscale risk surface)
+
+Every file below is a non-atomic read-modify-`writeFileSync` with no locking. On Replit autoscale the deployed image contains only the git-committed seed; every runtime write lands on instance-local disk and is lost on the next deploy or scale event. Multiple instances fork state.
+
+| File | Written by | Lost-on-redeploy impact |
+|---|---|---|
+| `subscribers.json` | `/api/subscribe`, `/api/unsubscribe`, admin delete (`routes.ts:1945`) | **Critical** — only durable copy is Resend, and only if `RESEND_API_KEY` is set. Not in git. |
+| `interconnection-queue.json` | admin backlog routes + news scanners (`routes.ts:850,3098`) | High — admin edits + auto-updates revert to the May 20 seed |
+| `datacenters.json` | admin CRUD + ingester approve (`routes.ts:2592`) | High — added/approved sites revert |
+| `datacenters-pending.json` | ingester (`datacenter-ingester.ts:293`) | High — pending queue + reject state revert |
+| `index-history.json` | `/api/kpis` daily append (`index-history.ts:102`) | Medium — regenerable via `npm run backtest:indices` |
+| `market-constants.json` | uranium news scanner (`routes.ts:382`) | Medium — self-heals on next news match |
+| `social-log.json` | tweet cron + manual posts (`routes.ts:1440`) | Medium — audit trail only |
+| `backlog-auto-updates.json` | backlog scanner (`routes.ts:744`) | Low — audit trail only; never in git |
+
+## Module inventory
+
+**Server (`server/`, ~5.9K LOC).** `routes.ts` (3,485 lines — the god-file: ~60 endpoints, inline `COMPANY_DATABASE` + `STATIC_MARKET_DATA` datasets, HMAC unsubscribe, RSS fetch, news→file scanners, X OAuth client, OG image route, tweet composers, admin CRUD). Cleanly factored modules: `indices.ts` (pure index math, unit-tested), `physical.ts` (FRED/EIA), `index-history.ts` (daily series append), `datacenter-ingester.ts` (RSS auto-discovery + approve/reject queue), `social-format.ts` (tweet copy formatters, unit-tested), `seo.ts` (per-route meta injection), `static.ts`/`vite.ts` (SPA serving), `index.ts` (boot, helmet, rate limits, logging, error handler).
+
+**Client (`client/src/`, ~12.5K LOC).** Wouter routes in `App.tsx`. Pages: TiltOverview (1,369), PowerMap (1,264, Leaflet + CartoDB), SupplyChain (926, D3 force graph from `data/supply-chain-config.ts`), TheTrade (713), TheStack (618), CatalystTracker (536), Queue (411), plus programmatic stock/sector/region/operator/blog pages and two admin consoles. TanStack Query defaults: `staleTime: Infinity`, `retry: false`, `refetchOnWindowFocus: false` (`lib/queryClient.ts`).
+
+**Build.** `script/build.ts`: Vite builds the client to `dist/public`; esbuild bundles `server/index.ts` → `dist/index.cjs` (CJS, minified, most deps left external). Production reads `server/data/`, `server/fonts/`, and `content/blog/` from `process.cwd()` — the dist artifact is **not** self-contained; it depends on the full workspace checkout being present at runtime.
+
+## Boot sequence (`server/index.ts`)
+
+1. `trust proxy = 1` (correct for Replit's single TLS proxy → real client IP for rate limiting).
+2. helmet CSP + HSTS (prod) + frame/referrer policies; `x-powered-by` disabled.
+3. Global limiter: 120 req/min/IP on `/api/*`.
+4. JSON/urlencoded body parsers, 100 kB limit.
+5. Request logger (captures and logs full JSON response bodies except for 3 hardcoded sensitive routes).
+6. `registerRoutes()` mounts all endpoints and per-route limiters; starts the datacenter ingester (`setInterval` 6h).
+7. Global error handler (returns `{ message }`, no stack).
+8. Prod → `serveStatic`; dev → Vite middleware. Listen on `PORT || 5000`.
+
+## Scheduling
+
+No in-process scheduler for social. The daily X post is driven by an **external cron** (cron-job.org) hitting `POST /api/admin/cron/daily-tweet` with the admin key on weekday mornings; template is chosen by `new Date().getDay()`. RSS/news refresh is pull-based (only refreshes when `/api/news` is hit and the cache is stale). The datacenter ingester and the LBNL edition check are the only in-process timers.
diff --git a/docs/audit/00_baseline.md b/docs/audit/00_baseline.md
new file mode 100644
index 0000000..8cba984
--- /dev/null
+++ b/docs/audit/00_baseline.md
@@ -0,0 +1,82 @@
+# Phase 0 — Baseline Report
+
+> Captured 2026-06-09 on `main` @ `dbbf7ac` (last commit "Ingester pickup", 2026-06-04). Working tree was pristine apart from an untracked `.github/`. Everything below was actually run/observed, not assumed.
+
+## Repo state
+
+- Branches: `main` (default), `redesign/home-swiss` (stale — see findings), and this `audit/phase-0-1`.
+- Untracked: `.github/workflows/ci.yml` — a complete, working CI pipeline (checkout, Node 20, `npm ci`, check, test, build, `npm audit --omit=dev`) that **was never committed**, so it has never run on GitHub.
+- Toolchain in this environment: Node v26.0.0, npm 11.12.1, macOS 15.6.1. (Note: the repo targets Node 20; it runs on 26 locally but that is not the deploy target.)
+
+## Quality gates — all green
+
+| Gate | Command | Result |
+|---|---|---|
+| Typecheck | `npm run check` (`tsc`) | **Pass**, 0 errors. (tsconfig excludes `**/*.test.ts`, so test files are never typechecked.) |
+| Tests | `npm test` (`node --test`) | **31/31 pass** across 5 server-only files (`indices`, `social-format`, `datacenter-ingester`, `physical`, `supply-chain-throttle`). No client/component/e2e tests exist. |
+| Build | `npm run build` | **Pass** in ~19s. Bundle warning emitted (see below). |
+| Index backtest | `npm run backtest:indices` | **Pass** — reproduces the series deterministically from public prices + FRED. |
+
+## Bundle analysis (production client build)
+
+```
+dist/public/assets/index-DJewIaCg.js 1,205.13 kB │ gzip: 343.46 kB ← single oversized entry chunk
+dist/public/assets/index-C4CEv7ch.css 96.06 kB │ gzip: 21.64 kB
+dist/public/assets/TiltOverview-*.js 63.67 kB │ gzip: 16.69 kB (lazy)
+dist/public/assets/Home-*.js 19.58 kB │ gzip: 6.30 kB (lazy)
+dist/public/assets/power-map-*.js 14.11 kB │ gzip: 5.23 kB (split via dynamic topojson import)
+Server bundle dist/index.cjs ~1.1 MB
+```
+
+The 1.2 MB entry chunk is the headline performance problem: only `Home` and `TiltOverview` are `React.lazy`; every other page (and therefore D3, Leaflet, and Recharts) is statically imported in `App.tsx` and lands in the entry chunk that even a marketing-only visitor to `/` must download. Vite prints the "chunks larger than 500 kB" warning. Details in findings (PERF-1).
+
+## Index backtest output (correlation summary)
+
+```
+AI Demand vs physical electricity output (lead 0/1/2/3): -0.21 / -0.13 / -0.01 / -0.16 n=88
+Grid Stress: -0.15 / 0.04 / 0.09 / 0.11 n=52
+NPI redundancy (daily-return r vs equity legs): VST r=0.95, CEG r=0.88, NLR r=0.72, CCJ r=0.61
+NPI vs Grid Stress signal: r=0.96
+```
+
+This reproduces the published conclusion: the gauges carry **no physical-grid signal** and are correctly labeled market-sentiment gauges. The three-way agreement between the README "Index methodology" section, `server/indices.ts`, and `docs/INDEX_VALIDATION.md` is **exact** — this is the strongest part of the codebase and must be preserved. (Re-running the backtest regenerates `index-history.json` and `INDEX_VALIDATION.md` with newer dates; those regenerated files were reverted to keep the audit tree clean.)
+
+## Local endpoint baseline
+
+Booted locally with placeholder `UNSUB_TOKEN_SECRET` / `ADMIN_API_KEY`. All key endpoints return 200 with sensible shapes:
+
+| Endpoint | Result |
+|---|---|
+| `GET /api/kpis` | 200 — `aiPowerIndex 70.7`, `npiValue 252.5`, `gridStress 67.5`, `source:"live"` |
+| `GET /api/index-history` | 200 — 611 daily rows, latest `2026-06-09` |
+| `GET /api/queue` | 200 — 60 projects, `lastRefreshed 2026-05-20`, `trackedCapacityGW 38.7` |
+| `GET /api/earnings-calendar` | 200 — 77 items, soonest first |
+| `GET /api/datacenters` | 200 — 58 sites |
+| `GET /api/stack?timeframe=1D` | 200 in ~3.7s — 100 tickers across 13 layers, 4 stale, `correlationCoeff 0.818` |
+| `GET /api/supply-chain` | 200 — 5 stages, 5/84 stale, tightest "Transmission" |
+| `GET /api/catalysts/all` | 200 — 85 merged items |
+
+Auth + abuse probes:
+- `GET /api/admin/subscribers` → **401** with no key, **401** with wrong key, **200** with correct key. Admin gate works.
+- `GET /api/newsletter/preview` → **200 with no key** (leaks subscriber count). ⚠️ finding SEC-1.
+- `POST /api/social/generate` → **200 with no key** (drives Yahoo fetches, leaks pre-publish copy). ⚠️ finding SEC-2.
+- `GET /api/stack?timeframe=ZZZ` → 200 in ~3.8s — an arbitrary timeframe string is a **cache miss that triggers a full ~200-call Yahoo fetch**, bypassing the 10-min cache. ⚠️ finding SEC-3.
+- Dev `Permissions-Policy` header: **absent**, despite SECURITY.md claiming it. ⚠️ finding DOC-2.
+
+## Production parity — the "ACTIVE BLOCKER" is resolved
+
+The May 21 deploy blocker recorded in HANDOFF.md is **stale, not active**. Live probes of gridtilt.com show production is on a build from ~June 4 with all the post-blocker features:
+- `/` serves the marketing home (the `/` → `/overview` route split shipped).
+- `manifest.json` `start_url` is `/overview`.
+- `/api/index-history` returns `generated: 2026-06-04` with the full series.
+- `/api/queue` returns the same 60 projects / `2026-05-20` as local.
+- `/api/kpis` returns live values.
+- Production security headers are present and slightly tighter than SECURITY.md documents (CSP `script-src 'self'`, no Twitter origins).
+
+One probe (`/api/physical/electricity-output`) failed to respond in a single attempt — most likely a transient upstream FRED timeout rather than a missing endpoint, since it shipped in the same June 3–4 batch as `/api/index-history` which is live. Worth one re-check before relying on it.
+
+**Conclusion:** main is deployed; no production-parity work is blocking new development. Safe to build on top.
+
+## Docs-vs-reality, top line
+
+The index-methodology documentation is exemplary. Almost everything else has drifted. The single worst lie, repeated in three docs, is a **persistence layer that does not exist**: README/CLAUDE.md/HOMEPAGE_HANDOFF.md describe Drizzle ORM, optional Postgres, `server/storage.ts` with an `IStorage` interface, and a `shared/` directory. Verified absent: `shared/` and `server/storage.ts` do not exist, `package.json` has **0** drizzle/pg references, and there are **0** `DATABASE_URL` references in the code. Full reconciliation is in `01_findings.md` (DOC-1).
diff --git a/docs/audit/01_findings.md b/docs/audit/01_findings.md
new file mode 100644
index 0000000..9a07645
--- /dev/null
+++ b/docs/audit/01_findings.md
@@ -0,0 +1,170 @@
+# Phase 1 — Audit Findings
+
+> `main` @ `dbbf7ac`, 2026-06-09. Every finding has an ID, a severity, file:line evidence, and a recommendation. IDs are referenced by `02_plan.md`. Severity = blast radius × likelihood for a single-instance public app on ephemeral hosting. Items marked ✓verified were reproduced directly during this audit; others are cited from the code read.
+
+## Severity summary
+
+| ID | Severity | One-line |
+|---|---|---|
+| DATA-1 | **Critical** | Subscriber list lives only in instance-local JSON; lost on every Replit redeploy unless Resend is configured |
+| DOC-1 | **Critical** | README/CLAUDE.md describe a Drizzle/Postgres/`storage.ts`/`shared/` persistence layer that does not exist |
+| SEC-1 | **High** | `/api/newsletter/preview` is unauthenticated and leaks the subscriber count ✓verified |
+| SEC-2 | **High** | `/api/social/generate` is unauthenticated; burns Yahoo quota and leaks pre-publish copy ✓verified |
+| SEC-3 | **High** | `/api/stack?timeframe=` is unvalidated → cache-bypass Yahoo-quota amplification ✓verified |
+| SEC-4 | **High** | Public `GET /api/news` writes persisted data files via news scanners |
+| SEC-5 | **High** | Stored-XSS vector: datacenter `name` interpolated into Leaflet `divIcon` HTML |
+| DATA-2 | **High** | Admin backlog/datacenter edits silently revert on redeploy (advertised as durable) |
+| PERF-1 | **High** | Inverted code-splitting: D3+Leaflet+Recharts ship in a 1.2 MB entry chunk to every visitor ✓verified |
+| TYPE-1 | **High** | No shared client/server type contract; KPI/catalyst shapes defined 2–3× and already drifted |
+| CI-1 | **High** | A finished CI workflow exists but is untracked, so nothing gates pushes to `main` ✓verified |
+| TEST-1 | **High** | The admin-auth boundary and HMAC unsubscribe flow have zero test coverage |
+| ARCH-1 | **High** | `routes.ts` is a 3,485-line god-file mixing ~60 endpoints, inline datasets, crypto, and the X client |
+| SEC-6 | Medium | Unsubscribe tokens have no expiry/revocation — a leaked link works forever |
+| SEC-7 | Medium | SECURITY.md claims controls (Twitter CSP, Permissions-Policy) the code does not enforce ✓verified |
+| DATA-3 | Medium | All JSON writes are non-atomic, lock-free; corruption is silently swallowed as `[]` |
+| DATA-4 | Medium | Deploy artifact is not self-contained; depends on full workspace at `process.cwd()` |
+| CORR-1 | Medium | Stack page ships synthetic (randomly generated) CCJ/CEG correlation scatter data |
+| CORR-2 | Medium | Earnings calendar uses two inconsistent "today" definitions (UTC vs server-local) |
+| EXP-1 | Medium | Express 5: async route throws aren't auto-forwarded; `/api/kpis` has no try/catch |
+| A11Y-1 | Medium | D3 graph and Leaflet map are mouse-only; no keyboard path, no screen-reader fallback |
+| A11Y-2 | Medium | Keyboard-shortcuts modal is a hand-rolled div with no dialog semantics/focus trap |
+| QUERY-1 | Medium | `retry:false` + `staleTime:Infinity` + no refetch traps pages in a permanent error/stale state |
+| DOC-3 | Medium | README module/stack counts wrong (8→13 layers, 21/44→24/52 nodes/links); replit.md is a fossil |
+| DOC-4 | Medium | HANDOFF/HOMEPAGE_HANDOFF/CLAUDE.md describe shipped work as open roadmap |
+| DEAD-1 | Medium | Stale market claims baked into the client bundle (`supply-chain-config.ts`) |
+| DESIGN-1 | Medium | 534 hardcoded hex literals; `#F07800` is not a Tailwind token |
+| DEP-1 | Low | Tailwind v4 vite plugin sits unused alongside active Tailwind 3; unused deps; misplaced `@types` |
+| DEAD-2 | Low | Dead files/branches: `ui/dialog`, `ui/toggle`, `lib/dates`, duplicated datasets, `redesign/home-swiss` |
+| PORT-1 | Low | `reusePort: true` in `listen()` throws `ENOTSUP` on macOS — blocks local `npm run dev` ✓verified |
+| NAV-1 | Low | 6 dashboard breadcrumbs link to `/`, now ejecting users to the marketing page |
+
+---
+
+## Critical
+
+### DATA-1 — Subscriber list is ephemeral
+**`server/routes.ts:1927,1944-1946`.** `subscribers.json` is the documented single source of truth for the mailing list, written with `writeFileSync` to instance-local disk. On Replit autoscale the deployed image has no such file, so a redeploy/scale event resets the list to empty; multiple instances each keep their own copy. The only durable mirror is Resend, and only when `RESEND_API_KEY` is set (`routes.ts:2026`). This is the one dataset that cannot be re-derived or re-committed from local dev. Unsubscribe state is lost the same way (a compliance problem).
+**Fix:** introduce a real durable store. Recommended: a small Postgres (Neon free tier) or SQLite-on-a-volume behind a genuine `IStorage` interface — which also makes the phantom DOC-1 abstraction real. Minimum viable: make Resend the source of truth and treat the JSON as a cache; fail loudly if neither is configured in production.
+
+### DOC-1 — Phantom persistence layer
+**`README.md:58,91,104-107`; `CLAUDE.md:7,13-14`; `HOMEPAGE_HANDOFF.md:27`.** All three claim Drizzle ORM, optional Postgres via `DATABASE_URL`, `server/storage.ts` with an `IStorage` interface, and a `shared/` directory of Drizzle schemas. ✓verified absent: `shared/` and `server/storage.ts` do not exist; `package.json` has 0 drizzle/pg refs; 0 `DATABASE_URL` references anywhere in code. `HANDOFF.md:139` already admits "the drizzle/users scaffolding was deleted." A future engineer/agent told `DATABASE_URL` makes subscribers durable will lose data by trusting it.
+**Fix:** either build the real abstraction (preferred, pairs with DATA-1) or delete every claim. The docs must match the code before anything else, because agents act on them.
+
+---
+
+## High
+
+### SEC-1 — Unauthenticated newsletter preview ✓verified
+**`server/routes.ts:2081-2137`.** `GET /api/newsletter/preview` has no `requireAdmin` call. It sits under the `/api/newsletter/` prefix so it gets the *failed-auth* limiter, but it never authenticates, so it is fully public and renders "Sent to N subscribers" — leaking exact list size. Returned **200 with no key** in testing.
+**Fix:** add `requireAdmin`, or compute the count only in the admin send path.
+
+### SEC-2 — Unauthenticated social compose ✓verified
+**`server/routes.ts:2933-2955`.** `POST /api/social/generate` has no `requireAdmin`. Anonymous callers drive live Yahoo fetches (quota burn) and read composed tweet copy before publish. The admin UI sends `x-admin-key` here but the server never checks it. Returned **200 with no key**.
+**Fix:** add `requireAdmin`.
+
+### SEC-3 — Stack cache bypass / Yahoo-quota amplification ✓verified
+**`server/routes.ts:1760-1763,496`.** `/api/stack` reads `req.query.timeframe` unvalidated and uses it directly as the `stackCache` key. Any unrecognized string is a permanent cache miss → ~200 Yahoo calls per request, defeating the 10-minute cache the threat model relies on, and growing the cache map unbounded. `?timeframe=ZZZ` returned 200 in ~3.8s (full fetch). `chartOptsForTimeframe` already normalizes unknown values to "1D" for the fetch — but not for the cache key.
+**Fix:** validate `timeframe` against an allowlist (`1D|5D|1W|1M`...) and key the cache on the normalized value; reject or coerce others.
+
+### SEC-4 — Public input writes to disk
+**`server/routes.ts:2348,2376-2378,2392-2394`.** A public `GET /api/news` runs `scanNewsForBacklogUpdates` and `scanNewsForMarketConstants`, which **write** `interconnection-queue.json` and `market-constants.json` from RSS/NewsData headline matches. An attacker who lands a crafted headline in one of the 8 sources can move displayed queue/uranium figures within the sanity bands, with no auth and only the global limit.
+**Fix:** gate the scanners behind the admin/cron path (they already have a manual `/api/admin/scan-news-now`), or move them off the public read path entirely; persist only from an authenticated trigger.
+
+### SEC-5 — Stored XSS via datacenter name
+**`client/src/pages/PowerMap.tsx:332` (and the popup builder ~143-170).** `dc.name` is interpolated into `L.divIcon({ html: ... })` without escaping. Datacenter names enter through the admin form and the ingester pipeline, so a malicious/garbled name renders as live HTML in every visitor's map.
+**Fix:** escape on render (or sanitize on write in `validateDatacenter`). Prefer escaping at the render boundary so existing data is covered.
+
+### DATA-2 — Admin edits silently revert
+**`server/routes.ts:3083-3085,3098,2592`.** The backlog admin routes are explicitly sold as "keep the dataset fresh from anywhere without a redeploy," and datacenter CRUD writes JSON the same way. On autoscale every such edit reverts to the committed seed on the next deploy. Same root cause as DATA-1; called out separately because the feature actively promises durability it doesn't have.
+**Fix:** same durable-store solution as DATA-1, or relabel these as "edits persist until next deploy; commit to make permanent."
+
+### PERF-1 — Inverted code-splitting ✓verified
+**`client/src/App.tsx:9-34`.** Only `Home` and `TiltOverview` are `React.lazy`; every other page is statically imported at module top, so D3 (SupplyChain), Leaflet (PowerMap), and Recharts (TheStack/TheTrade/StockPage/PortfolioOverlay) all land in the 1.2 MB entry chunk (343 kB gzip) that a `/` marketing visitor downloads before the lazy Home chunk. The code comment claims the opposite ("each visitor only pays for the chunks they need").
+**Fix:** `React.lazy` every route component; add `manualChunks` for the heavy viz libs; verify with a rebuild + bundle diff.
+
+### TYPE-1 — No client/server type contract
+**`client/src/lib/types.ts` vs `client/src/pages/TiltOverview.tsx:63-78`, `CatalystTracker.tsx:15-45`.** Response types are hand-maintained local interfaces casted onto `useQuery` — no shared module, no runtime validation. The KPI shape is defined twice and has already drifted (`lib/types.ts` includes `source`/`asOf`; TiltOverview omits them); the catalyst shape exists in three places; `/api/queue` is typed `any`.
+**Fix:** create a real `shared/` (resurrecting the name the docs already promise) with the response types — ideally derived from zod schemas so the server validates and the client imports one source of truth.
+
+### CI-1 — CI exists but is inert ✓verified
+**`.github/workflows/ci.yml` (untracked).** A complete pipeline (check/test/build/`npm audit --omit=dev`) sits in the working tree uncommitted, and on the stale `redesign/home-swiss` branch. Nothing runs on push to `main`.
+**Fix:** commit it on `main`. Extend later with the new client/e2e tests.
+
+### TEST-1 — Untested security boundary
+**`server/__tests__/`.** The 31 passing tests cover index math, tweet copy, the ingester, FRED parsing, and the throttle path — but not `requireAdmin` (no test that admin routes 401 without a key / 503 without the env / 200 with it) and not the HMAC unsubscribe flow. A "every admin route rejects anonymous" test would have caught SEC-1 and SEC-2.
+**Fix:** add an auth-boundary integration test enumerating every admin/mutating route; add unsubscribe token tests (forge, cross-email, expiry once SEC-6 lands).
+
+### ARCH-1 — `routes.ts` god-file
+**`server/routes.ts` (3,485 lines).** One file holds ~60 endpoints, the inline `COMPANY_DATABASE` (53-172) and `STATIC_MARKET_DATA` (222-344) datasets, the news→file scanners (630-984), the OG renderer (1056-1216), the full X OAuth 1.0a client (1218-1412), the tweet composers, and all admin CRUD. Helpers like `STAGE_MAP` rebuild per boot inside `registerRoutes`. Duplicated logic: the top-movers sort appears 5×; "load+parse queue JSON" 7×; earnings selection in 3 places; `escapeXml`/`escapeHtml` duplicated across files.
+**Fix:** decompose into domain routers (`markets`, `supply-chain`, `catalysts`, `datacenters`, `queue`, `newsletter`, `social`, `seo`, `news`); lift datasets to `server/data/*.ts`; lift the X client, scanners, and OG renderer to their own modules. Cleanest first extractions: X client, news scanners, OG renderer.
+
+---
+
+## Medium
+
+### SEC-6 — Replayable unsubscribe tokens
+**`server/routes.ts:1955-1956,2056-2063`.** The token is a static `HMAC-SHA256(email)` with no timestamp/nonce/version. It can't be forged (good) or used across subscribers (good), but a captured link works forever and can't be individually revoked short of rotating the global secret (which invalidates everyone's links).
+**Fix:** add a version/issued-at component, or accept the tradeoff explicitly in SECURITY.md. Low urgency; note alongside the auth work.
+
+### SEC-7 — SECURITY.md overstates the posture ✓verified
+**`SECURITY.md:34,42,51` vs `server/index.ts:62-74`.** The doc claims prod CSP `script-src/frame-src` include `https://platform.twitter.com` (the code ships `'self'` only — tighter, the Twitter widget was removed) and claims a `Permissions-Policy: camera=() microphone=() geolocation=()` header that is **never set** (helmet 8 doesn't emit it by default; absent in dev probe). A security doc that overstates controls is worse than none.
+**Fix:** make the doc match the code (and optionally actually set Permissions-Policy, which is cheap and worth having).
+
+### DATA-3 — Non-atomic, lock-free writes
+**`server/routes.ts:1945,2592,3098`; `server/datacenter-ingester.ts:457-459`.** Every persisted file is a read-modify-`writeFileSync` with no temp-file-rename and no locking. A crash mid-write truncates the JSON, which loaders swallow as `[]` (silent data loss). `approvePendingDatacenter` writes two files separately; a crash between duplicates the entry. Concurrent ingester (6h) + admin write can interleave.
+**Fix:** centralize JSON IO in one helper that writes to a temp file and renames; once a real store lands (DATA-1) this mostly evaporates.
+
+### DATA-4 — Artifact not self-contained
+**`script/build.ts`; `server/routes.ts:1141,2762`; `server/static.ts:7`.** The build copies nothing beyond `index.cjs` + `dist/public`. Production reads `server/data/`, `server/fonts/`, and `content/blog/` from `process.cwd()`. Works on Replit (whole workspace is snapshotted) but breaks the moment anyone deploys "just dist/" (Docker, another host) — data, fonts, blog, and sitemap all fail at once.
+**Fix:** add a copy step to `build.ts` (or resolve paths relative to the bundle), so dist is portable. At minimum, document the dependency.
+
+### CORR-1 — Synthetic correlation data on the Stack page
+**`server/routes.ts:409-458,1765-1766`.** `generateCCJCorrelationData`/`generateCEGCorrelationData` build the uranium-vs-equity scatter with `Math.random()` (Box-Muller) tuned to a target Pearson r, then `/api/stack` ships it as `correlation`/`correlationCoeff`. For a product whose brand is "check the math," presenting randomly generated points as a correlation chart is the highest-integrity-risk item outside the indices.
+**Fix:** either compute the correlation from real historical price series (the backtest already fetches them) or label the scatter unambiguously as an illustrative model — consistent with how the indices are disclosed. Recommend computing it for real.
+
+### CORR-2 — Earnings "today" boundary inconsistency
+**`server/routes.ts:2244-2246,2285` vs `2495,2417`.** `refreshEarningsCache` filters with a millisecond `todayMs` from server-local midnight, then emits dates as UTC `toISOString().slice(0,10)`; `getEarningsData`/`loadManualCatalysts` re-filter with a string `todayStr` from UTC. Two different "today" definitions on a UTC container can label an AMC earnings one day off — the recurring class of bug HANDOFF flags ("NVDA at +8 days when it had reported"). The "any positive timestamp is a candidate" heuristic can also resurface a just-reported date as upcoming.
+**Fix:** pick one timezone (US/Eastern, the market tz) for all date logic; unit-test the boundary (TEST gap).
+
+### EXP-1 — Express 5 async error forwarding
+**`server/index.ts:156-167`; `server/routes.ts:1727`.** Express 5 does not auto-forward rejections from async handlers to the error middleware. Most handlers use try/catch, but `/api/kpis` (and a few others) don't; a throw becomes an unhandled rejection instead of a clean 500. `computeKpis` guards its internals, so risk is low today but latent.
+**Fix:** wrap async handlers in an `asyncHandler` helper, or add try/catch to the bare ones. Comes mostly for free during the ARCH-1 router split.
+
+### A11Y-1 / A11Y-2 — Maps/graphs and modal
+**`client/src/pages/SupplyChain.tsx`, `PowerMap.tsx`; `client/src/App.tsx:67-113`.** The D3 graph and Leaflet markers are mouse-only (no `tabIndex`/`onKeyDown`/ARIA, no sr-only data table). The shortcuts modal is a hand-rolled fixed div with no `role="dialog"`, `aria-modal`, or focus trap — while a proper Radix `Dialog` sits unused in `ui/dialog.tsx`.
+**Fix:** add sr-only fallback tables for chart/map data; swap the modal to Radix Dialog; add keyboard affordances to map/graph selection.
+
+### QUERY-1 — Stale/error traps
+**`client/src/lib/queryClient.ts:44-57`.** `retry:false` + `staleTime:Infinity` + `refetchOnWindowFocus:false` + no `refetchInterval` on several pages (PowerMap, BlogIndex) means one transient failure leaves a page permanently errored or stale until a full reload. Several pages also don't destructure `isError` (CatalystTracker, SectorPage), and three custom `queryFn`s skip the `res.ok` check (TheStack:257, SectorPage:70, NewsTicker:14).
+**Fix:** allow 1–2 retries, add modest `refetchInterval` to live pages, handle `isError` everywhere, route all fetches through `apiRequest` (which checks `res.ok`).
+
+### DOC-3 / DOC-4 — Documentation drift
+**README.md:16,18; replit.md (whole file); HANDOFF.md; HOMEPAGE_HANDOFF.md; CLAUDE.md:27-28.** README says "8 layers / 60+ tickers" (actual 13 layers / 100 tickers, ✓counted) and "21 nodes / 44 links" (actual 24 / 52, ✓counted in `supply-chain-config.ts`). `replit.md` (May 18) still references react-simple-maps, an org-chart supply chain, SESSION_SECRET for unsubscribe, and 4 RSS feeds — all wrong. HANDOFF/HOMEPAGE_HANDOFF/CLAUDE.md present the LBNL queue, the homepage redesign, and the May 21 blocker as open when all shipped weeks ago; CLAUDE.md still calls the redesign "active" and the anchor "Swiss" after Swiss was explicitly torn out.
+**Fix:** correct README counts; delete or rewrite replit.md; stamp both handoffs "historical — superseded"; fix CLAUDE.md's redesign section and the G+1–G+9 shortcut count.
+
+### DEAD-1 — Stale claims in the bundle
+**`client/src/data/supply-chain-config.ts:35-62`.** Point-in-time figures ("Spot at $93/lb", "$14,200/ton", "Sold out HBM3E 2026", "$200B+ GEV backlog") are hardcoded into the client bundle and go stale silently. They belong server-side next to `/api/supply-chain`, which already carries live bottleneck status.
+**Fix:** move the volatile metrics server-side; keep the graph topology in the client config.
+
+### DESIGN-1 — No brand token
+**Across `client/src` (534 hex literals).** `#F07800` exists only as an HSL `--primary` and `--mkt-accent`, never as a Tailwind color token, so it's hardcoded 181×; `#F0A500` 147×. Worst offenders: PowerMap (110), TiltOverview (96).
+**Fix:** add `brand`/`brand-amber` Tailwind tokens and migrate literals incrementally.
+
+---
+
+## Low
+
+- **DEP-1** — `@tailwindcss/vite` ^4 sits in devDeps unused while Tailwind 3.4 is what's actually wired (`postcss.config.js`); `topojson-client`, `date-fns`, `zod`, `zod-validation-error` are unused (the last three are also no-ops in the esbuild allowlist); `@types/d3|leaflet|topojson-client` are in `dependencies` not `devDependencies`; package is still named `rest-express`. (`package.json`)
+- **DEAD-2** — `client/src/components/ui/dialog.tsx`, `ui/toggle.tsx`, and `lib/dates.ts` have zero importers; `electricityData` and `manualCatalysts` are duplicated between client config and the page/server; `.gt-pulse` is referenced but defined in no stylesheet; the `redesign/home-swiss` branch is dead, holding only the CI file. Confirmed clean: no `WhatsHappening` or `react-simple-maps` remnants.
+- **CORR-3** — `STATIC_MARKET_DATA` is mutated in place as a write-through cache (`routes.ts:521-524`); benign on single-threaded Node but means the "static" fallback isn't static.
+- **PORT-1** ✓verified — `httpServer.listen({ ..., reusePort: true })` (`server/index.ts:188`) throws `ENOTSUP` on macOS, so `npm run dev` fails locally out of the box. `reusePort` buys nothing on single-instance Replit. Recommend gating it behind a non-darwin check or removing it.
+- **NAV-1** — Six dashboard breadcrumbs (`SectorPage:98`, `RegionPage:67`, `OperatorPage:72`, `StockPage:103`, `BlogPost:175`, `BlogIndex:25`) link `href="/"`, which now lands on the marketing page instead of the dashboard; should point to `/overview`.
+- **LOG-1** — The request logger appends full JSON response bodies for all non-redacted `/api` routes with a hardcoded 3-route redaction list (`server/index.ts:140-143`); brittle as new sensitive routes are added.
+
+## Confirmed healthy (do not "fix")
+
+- The index math chain (README ↔ `server/indices.ts` ↔ `INDEX_VALIDATION.md`) agrees exactly and is well unit-tested. Preserve the disclosure discipline.
+- Yahoo throttle handling emits `stale:true` with `null` change rather than zero-fill, and it's covered by `supply-chain-throttle.test.ts`. Don't reintroduce zero fallbacks.
+- `requireAdmin` uses `timingSafeEqual` correctly and returns 503 (not 401) when the key env is unset. Admin gate works where it's actually applied.
+- `trust proxy = 1` is the correct setting for Replit's single proxy.
+- SSRF/path-traversal: no `fs` path or outbound fetch URL is built from user input. Clean.
diff --git a/docs/audit/02_plan.md b/docs/audit/02_plan.md
new file mode 100644
index 0000000..23f7183
--- /dev/null
+++ b/docs/audit/02_plan.md
@@ -0,0 +1,71 @@
+# Phase 2 — Rebuild & Modernization Plan
+
+> Sequenced by value-at-risk and dependency. Each milestone is independently shippable, leaves `main` releasable, and lists scope / files / risk / verification / rollback. **Execution beyond docs needs owner sign-off** (guardrail: ask before merging to `main`, changing index methodology, changing the security posture, adding a paid/external dependency, or anything with runtime cost). Items needing an explicit decision are marked 🔶.
+
+## Recommended order
+
+```
+M0 Truth + CI (docs only + commit CI) ← start here, zero behavior change
+M1 Security closes (SEC-1..5, TEST-1) ← small, surgical, high value
+M2 Durability 🔶 (DATA-1..3, kills DOC-1) ← the architectural decision
+M3 Perf + type contract (PERF-1, TYPE-1, QUERY-1)
+M4 Decompose routes.ts (ARCH-1, EXP-1, CORR-2)
+M5 Integrity 🔶 (CORR-1 synthetic data)
+M6 Frontend quality (A11Y, DESIGN-1, DEAD, NAV-1)
+M7 New capability 🔶 (LBNL deepening + AI track, Phase 4)
+```
+
+---
+
+## M0 — Tell the truth, turn on CI
+**Scope:** Fix DOC-1/DOC-3/DOC-4 and the doc half of SEC-7; commit the existing `.github/workflows/ci.yml` (CI-1); fix PORT-1 so `npm run dev` works on macOS.
+**Files:** `README.md`, `CLAUDE.md`, `SECURITY.md`, `replit.md`, `HANDOFF.md`, `HOMEPAGE_HANDOFF.md`, `.github/workflows/ci.yml`, `server/index.ts`.
+**Risk:** Negligible — docs + a CI file + one `listen()` option. **Verification:** `git add .github && push`, watch the Actions run go green; `npm run dev` boots locally. **Rollback:** revert commit.
+**Why first:** every future session reads these docs and acts on them; the phantom persistence layer is actively dangerous. Turning on CI protects everything after.
+
+## M1 — Close the security gaps
+**Scope:** Add `requireAdmin` to SEC-1 and SEC-2; validate+normalize `timeframe` for SEC-3; move the news scanners off the public read path (SEC-4); escape datacenter names at the Leaflet render boundary (SEC-5). Add the TEST-1 auth-boundary integration test (enumerate every admin route → 401/503/200) plus unsubscribe-token tests.
+**Files:** `server/routes.ts` (5 small edits), `client/src/pages/PowerMap.tsx`, `server/__tests__/auth-boundary.test.ts` (new).
+**Risk:** Low; each change is a few lines with a test. SEC-4 needs care that the cron/manual scan path still works. **Verification:** new tests; re-run the curl probes (preview/social/generate → 401; `?timeframe=ZZZ` → coerced, single cached fetch). **Rollback:** per-change revert.
+
+## M2 — Durability 🔶
+**Scope:** Make subscriber + curated-dataset persistence survive redeploys (DATA-1, DATA-2), centralize atomic JSON IO (DATA-3), and make DOC-1's `IStorage` real instead of fictional.
+**Decision needed (🔶):** pick the store —
+- **(a) Neon Postgres + Drizzle** behind `IStorage` — matches what the docs already (falsely) claim; durable, queryable; free tier; small runtime dep. *Recommended.*
+- **(b) SQLite on a Replit persistent volume** — simplest, no external service, but ties durability to one instance.
+- **(c) Resend as source of truth for subscribers + keep JSON as cache** — smallest change, covers the Critical item only, leaves curated datasets ephemeral.
+**Files:** new `server/storage.ts` + `shared/` schema; migrate `subscribers`, `interconnection-queue`, `datacenters*`, `market-constants`, `social-log` read/writes in `routes.ts`.
+**Risk:** Medium — touches the subscriber path; needs a migration of the current JSON into the store and a careful cutover. **Adds a dependency / possible runtime cost → requires sign-off.** **Verification:** subscribe→redeploy→list persists; existing tests green; a seed/migration script. **Rollback:** feature-flag the storage backend; keep JSON path until verified.
+
+## M3 — Performance + type contract
+**Scope:** PERF-1 (lazy-load every route; `manualChunks` for d3/leaflet/recharts), TYPE-1 (real `shared/` response types, ideally zod-derived so the server validates and the client imports one source), QUERY-1 (retries, refetch, `isError`, route all fetches through `apiRequest`).
+**Files:** `client/src/App.tsx`, `vite.config.ts`, new `shared/types.ts`, `client/src/lib/queryClient.ts`, the page query hooks.
+**Risk:** Low-Medium; lazy-loading can surface Suspense-fallback gaps. **Verification:** bundle diff (entry chunk should drop well under 500 kB; viz libs in separate chunks); typecheck; Lighthouse before/after on `/` and `/overview`. **Rollback:** revert; chunks are build-time only.
+
+## M4 — Decompose `routes.ts`
+**Scope:** ARCH-1 — split into domain routers (`markets`, `supply-chain`, `catalysts`, `datacenters`, `queue`, `newsletter`, `social`, `seo`, `news`); lift `COMPANY_DATABASE`/`STATIC_MARKET_DATA` to `server/data/*.ts`; extract the X client, news scanners, OG renderer; dedupe the repeated helpers. Fold in EXP-1 (`asyncHandler`) and CORR-2 (single Eastern-tz date logic) during the move.
+**Files:** `server/routes.ts` → `server/routes/*.ts`, `server/social/`, `server/news.ts`, `server/og.ts`, `server/data/companies.ts`.
+**Risk:** Medium — large mechanical refactor; the danger is behavior drift. Do it one router at a time with the full test suite green between each, and an endpoint-parity check (diff responses before/after). **Verification:** every baseline endpoint returns an identical shape; tests green; `npm run check`. **Rollback:** per-router revert.
+
+## M5 — Integrity 🔶
+**Scope:** CORR-1 — replace the randomly generated CCJ/CEG correlation scatter with either a real computation from historical prices (the backtest already fetches them) or an unambiguous "illustrative model" label.
+**Decision needed (🔶):** compute-for-real (preferred, on-brand) vs relabel. Either way, **do not touch the three headline indices' methodology without explicit sign-off**, and update `INDEX_VALIDATION.md`/backtest if anything in the index path changes.
+**Files:** `server/routes.ts` (correlation generators), `client/src/pages/TheStack.tsx` (labels). **Risk:** Low. **Verification:** the scatter reflects real data or is clearly labeled; a unit test on the correlation computation. **Rollback:** revert.
+
+## M6 — Frontend quality
+**Scope:** A11Y-1/2 (sr-only fallback tables for chart/map data; swap the shortcuts modal to the unused Radix `Dialog`; keyboard affordances on map/graph), DESIGN-1 (`brand` Tailwind token + incremental literal migration), DEAD-1 (move volatile supply-chain metrics server-side), DEAD-2 (delete dead files; close `redesign/home-swiss` after rescuing CI), NAV-1 (breadcrumbs → `/overview`).
+**Files:** `tailwind.config.ts`, `client/src/pages/*`, `client/src/components/ui/*`, `client/src/data/supply-chain-config.ts`. **Risk:** Low, but visual — screenshot before/after. **Verification:** axe/Lighthouse a11y pass; visual diff; build. **Rollback:** revert.
+
+## M7 — New capability 🔶 (Phase 4)
+**Scope:** Deepen the LBNL interconnection-queue module (homepage card, richer `/queue`), then the AI feature track — the Analyst agent (sourced daily brief upgrading the X poster), natural-language dataset query, reasoned portfolio narratives, news-pipeline intelligence, vision-assisted capex ingestion. Each behind a feature flag + admin gating, with a kill switch, a cost ceiling, eval cases, and a "how we verified it isn't hallucinating" note. Never present a sentiment gauge as a measurement.
+**Decision needed (🔶):** this is net-new, costs money at runtime, and adds model calls → **full sign-off per feature.** Sequence and budget to be agreed before any build.
+**Risk:** Medium-High (cost, correctness, brand). **Verification:** eval suite per feature; dedupe/length checks preserved on the poster; owner approves copy before it ships. **Rollback:** feature flags off.
+
+---
+
+## Definition of done (Phase 5)
+`npm run check` / `npm test` / `npm run build` / `npm run backtest:indices` all green and run in CI on every PR; new tests cover the auth boundary and any touched client flows; all docs match reality; no security regression vs. the (corrected) threat model; index honesty preserved and backtest updated if any index-path code changed; a final `docs/audit/03_outcome.md` records what changed, what got measurably better (bundle size, type coverage, test count, Lighthouse), what's deferred, and the exact Replit redeploy steps.
+
+## Standing reminders
+- Pushing ≠ shipping on Replit. After any merge to `main`, say "pushed; redeploy on Replit to ship," never "shipped/live."
+- Keep `main` releasable; one concern per PR; self-review with a reviewer subagent before opening.
diff --git a/replit.md b/replit.md
index e90fb70..da16e50 100644
--- a/replit.md
+++ b/replit.md
@@ -1,49 +1,23 @@
# GridTilt | AI Infrastructure & Power Economy Dashboard
-## Overview
-GridTilt is a full-stack web application that visualizes the economic interplay between AI compute demand, power consumption, and financial markets. It provides a comprehensive dashboard for understanding and analyzing the AI infrastructure and power economy. The project aims to offer a unique perspective on how advancements in AI drive energy demands and influence market dynamics, with a vision to become the leading platform for tracking and analyzing the AI power sector.
-
-## User Preferences
-- No em dashes anywhere in the codebase.
-- Text style: short, direct sentences. No compound AI-sounding phrasing.
-- Dark mode only, with a warm charcoal terminal aesthetic and orange branding.
-
-## System Architecture
+> Short, accurate pointer. For full detail see `README.md`, `CLAUDE.md`, and `docs/ARCHITECTURE.md`. The previous version of this file was a May-18 snapshot that had drifted badly out of date; corrected 2026-06-09.
-### Frontend
-The frontend is built with React and TypeScript using Vite. It leverages `wouter` for routing, `TanStack Query v5` for data fetching, and `recharts` and `react-simple-maps` for data visualization. UI components are sourced from `shadcn/ui`, and styling is managed with `TailwindCSS` using a custom dark-mode-only theme.
-
-### Backend
-The backend is a Node.js and Express application. It fetches market data using `yahoo-finance2` and aggregates news from RSS feeds (Utility Dive, Data Center Dynamics, World Nuclear News, Power Engineering) with a fallback to `NewsData.io` if an API key is provided, otherwise utilizing static JSON. All API routes are prefixed with `/api`. The system includes fallbacks to static data when external APIs are unavailable.
+## Overview
+GridTilt is a research dashboard for the AI power economy — compute, data centers, generation, transmission, and the public equities around them. Live at gridtilt.com.
-### Key Features
-- **TiltOverview**: Dashboard with KPIs, Thesis Health, Top Movers, Sector Pulse, Catalyst Calendar, X feed, and a demand chart.
-- **TheStack**: An 8-layer sector breakdown with live stock data, timeframe toggles, and sort controls.
-- **PowerMap**: An interactive Leaflet map with CartoDB Dark Matter tiles displaying 48 data center locations with glowing animated pins, frosted-glass tooltips, floating stats overlay, collapsible filter panel, and a Grid Stress mode.
-- **TheTrade**: A Thesis Calculator offering preset and custom scenarios for infrastructure buildout, capex, and LPT outputs.
-- **PortfolioOverlay**: Provides AI Power Exposure scoring and a radar chart for portfolios.
-- **CatalystTracker**: Monthly calendar grid with colored event dots, upcoming earnings timeline with stage-colored nodes, thesis catalyst cards with category badges. Merged API (`/api/catalysts/all`) combines live Yahoo Finance earnings dates (for 8 curated large-cap tickers) with manual thesis catalysts. Seed dates serve as fallback if Yahoo is unavailable. Dashboard "Next 5 Catalysts" widget on TiltOverview.
-- **Stock, Sector, Region, Operator Pages**: Dedicated pages for detailed analysis of individual stocks, sectors, grid regions, and hyperscaler operators.
-- **Blog**: Features analysis articles on AI infrastructure and power economy topics.
-- **SupplyChain (V3)**: Org-chart tree layout (Root > 5 Systems > Sub-system icons) that fits in one viewport on desktop. CSS Grid + SVG connector lines (no React Flow). 20 sub-systems across 5 stages with data config in `supply-chain-config.ts`. Click sub-system to expand inline detail panel with description, key metrics, and company stock cards. Click system node for summary panel. Dimming/active states, staggered entrance animations. Responsive: 5-across desktop, wrapped tablet, vertical accordion mobile with 3-col subsystem grid.
-- **Subscribe / Email Capture**: Email newsletter signup at /subscribe, inline capture at bottom of TiltOverview, scroll-triggered banner. Backend stores subscribers in JSON file (server/data/subscribers.json). Newsletter send via Resend (needs RESEND_API_KEY). Unsubscribe tokens use HMAC-SHA256 with SESSION_SECRET.
+## Architecture
+- **Frontend:** React 18 + TypeScript + Vite, `wouter` routing, TanStack Query v5, Tailwind + shadcn/ui (dark mode only, warm charcoal + `#F07800` orange). Visualizations: Recharts (charts), D3 (supply-chain force graph), Leaflet + CartoDB Dark Matter (Power Map). No react-simple-maps.
+- **Backend:** Node 20 + Express 5. Market data via `yahoo-finance2` (unofficial, with labeled static fallback when throttled). News from 8 RSS feeds (Utility Dive, Data Center Dynamics, World Nuclear News, POWER Magazine, Power Engineering, Latitude Media, DOE, EIA) with optional NewsData.io. Physical electricity data from FRED and EIA. All API routes are prefixed `/api`.
+- **Persistence:** curated JSON datasets in `server/data/` (no database). On Replit autoscale these are ephemeral across deploys — see `docs/audit/01_findings.md`.
-### Visual Design
-The application features a dark charcoal terminal aesthetic with orange as the primary brand color for UI chrome, badges, and KPIs. Blue (`#1E90FF`) is strictly reserved for chart data series. The UI emphasizes short, direct sentences and avoids complex phrasing. Card radius is set to 0.35rem for a terminal-like appearance.
+## Key surfaces
+TiltOverview (`/overview`), TheStack (`/stack`, 100 tickers / 13 layers), PowerMap (`/power-map`, Leaflet), SupplyChain (`/supply-chain`, D3 force graph, 24 nodes / 52 links), CatalystTracker (`/catalysts`), TheTrade (`/trade`), PortfolioOverlay (`/portfolio`), Queue (`/queue`, LBNL interconnection backlog), Blog, Subscribe. Marketing home at `/`.
-### Global UX
-Includes a scrolling news ticker, keyboard shortcuts (`?` for modal, `G+1-6` for navigation), dynamic KPI cards with sparklines, and a shareable portfolio scoring feature. The `PowerMap` offers detailed filtering and a "Grid Stress" view. The `Thesis Calculator` provides scenario analysis with buildout charts and company rankings.
+## Conventions
+- No em dashes. Short, direct sentences; no marketing phrasing.
+- Dark mode only; `#F07800` brand orange; blue (`#1E90FF`) reserved for chart series.
+- Keyboard shortcuts: `?` for the modal, `G+1`–`G+9` for navigation.
+- Unsubscribe tokens use HMAC-SHA256 with `UNSUB_TOKEN_SECRET` (not SESSION_SECRET).
-## External Dependencies
-- **yahoo-finance2**: For fetching real-time market data.
-- **NewsData.io**: (Optional, with API key) For live news feeds.
-- **Recharts**: For charting and data visualization.
-- **leaflet** and **react-leaflet**: For interactive tile-based maps (CartoDB Dark Matter).
-- **shadcn/ui**: UI component library.
-- **TailwindCSS**: For styling and theming.
-- **wouter**: For client-side routing.
-- **TanStack Query v5**: For data fetching, caching, and state management.
-- **Node.js**: Backend runtime environment.
-- **Express**: Web application framework for the backend.
-- **satori** and **@resvg/resvg-js**: For dynamic OG image generation.
-- **RSS feeds**: Utility Dive, Data Center Dynamics, World Nuclear News, Power Engineering for news aggregation.
\ No newline at end of file
+## Deployment
+Replit autoscale. A push to GitHub does NOT auto-deploy — redeploy manually via Replit → Deployments. A push is not a ship.
diff --git a/server/__tests__/auth-boundary.test.ts b/server/__tests__/auth-boundary.test.ts
new file mode 100644
index 0000000..41a97f8
--- /dev/null
+++ b/server/__tests__/auth-boundary.test.ts
@@ -0,0 +1,138 @@
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+import express from "express";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+
+// Must be set before importing routes: registerRoutes throws at call time if
+// UNSUB_TOKEN_SECRET is unset, and requireAdmin returns 503 (not 401) when
+// ADMIN_API_KEY is unset.
+process.env.UNSUB_TOKEN_SECRET ||= "test-secret-for-auth-test";
+process.env.ADMIN_API_KEY ||= "test-admin-key";
+process.env.NODE_ENV = "test";
+const ADMIN_KEY = process.env.ADMIN_API_KEY;
+
+// Keep every test offline: stub Yahoo so no route reaches the network.
+interface YahooLike {
+ quote: (...args: unknown[]) => Promise;
+ chart: (...args: unknown[]) => Promise;
+ search?: (...args: unknown[]) => Promise;
+}
+const yahooModule = await import("yahoo-finance2");
+const YahooFinanceClass = (yahooModule as unknown as { default: new () => YahooLike }).default;
+const proto = YahooFinanceClass.prototype as YahooLike;
+const originalQuote = proto.quote;
+const originalChart = proto.chart;
+proto.quote = () => Promise.reject(new Error("offline"));
+proto.chart = () => Promise.reject(new Error("offline"));
+after(() => {
+ proto.quote = originalQuote;
+ proto.chart = originalChart;
+});
+
+const { registerRoutes } = await import("../routes");
+
+async function startTestServer(): Promise<{ url: string; close: () => Promise }> {
+ const app = express();
+ app.use(express.json());
+ const httpServer = createServer(app);
+ await registerRoutes(httpServer, app);
+ await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
+ const { port } = httpServer.address() as AddressInfo;
+ return {
+ url: `http://127.0.0.1:${port}`,
+ close: () => new Promise((resolve) => httpServer.close(() => resolve())),
+ };
+}
+
+// Every admin/mutating route plus the two that used to leak. requireAdmin runs
+// before any body handling, so empty bodies are fine for the no-key case.
+const PROTECTED_ROUTES: Array<[string, string]> = [
+ ["GET", "/api/admin/subscribers"],
+ ["GET", "/api/newsletter/preview"], // SEC-1: was public, leaked subscriber count
+ ["POST", "/api/social/generate"], // SEC-2: was public, burned Yahoo quota
+ ["DELETE", "/api/admin/subscribers/x@y.com"],
+ ["POST", "/api/newsletter/send"],
+ ["POST", "/api/admin/post-now"],
+ ["POST", "/api/admin/cron/daily-tweet"],
+ ["GET", "/api/admin/social-log"],
+ ["DELETE", "/api/admin/tweet/123"],
+ ["POST", "/api/admin/datacenters"],
+ ["DELETE", "/api/admin/datacenters/1"],
+ ["GET", "/api/admin/datacenters/pending"],
+ ["POST", "/api/admin/datacenters/ingest"],
+ ["POST", "/api/admin/datacenters/pending/1/approve"],
+ ["DELETE", "/api/admin/datacenters/pending/1"],
+ ["GET", "/api/export/daily"],
+ ["POST", "/api/admin/add-backlog-project"],
+ ["DELETE", "/api/admin/backlog-project/x"],
+ ["GET", "/api/admin/backlog-auto-updates"],
+ ["POST", "/api/admin/scan-news-now"],
+ ["POST", "/api/admin/update-backlog-headlines"],
+];
+
+test("admin auth boundary: the correct key is accepted, a missing key never authorizes", async () => {
+ const { url, close } = await startTestServer();
+ try {
+ // 1. Happy path FIRST. Successful (2xx) requests are skipped by the
+ // admin failure limiter, so they don't consume its budget.
+ const okSubs = await fetch(`${url}/api/admin/subscribers`, {
+ headers: { "x-admin-key": ADMIN_KEY },
+ });
+ assert.equal(okSubs.status, 200, "valid key should reach GET /api/admin/subscribers");
+ await okSubs.text();
+
+ const okLog = await fetch(`${url}/api/admin/social-log`, {
+ headers: { "x-admin-key": ADMIN_KEY },
+ });
+ assert.equal(okLog.status, 200, "valid key should reach GET /api/admin/social-log");
+ await okLog.text();
+
+ // 2. The three most important no-key cases come first, while the failure
+ // limiter (5/min) still lets them through to the handler → exact 401.
+ for (const path of ["/api/admin/subscribers", "/api/newsletter/preview"]) {
+ const res = await fetch(`${url}${path}`);
+ assert.equal(res.status, 401, `${path} without a key must be 401, got ${res.status}`);
+ const body = (await res.json()) as { error?: string };
+ assert.equal(body.error, "Unauthorized", `${path} should report Unauthorized`);
+ }
+ const gen = await fetch(`${url}/api/social/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(gen.status, 401, `POST /api/social/generate without a key must be 401, got ${gen.status}`);
+ await gen.text();
+
+ // 3. Every protected route: a missing key must NEVER return a 2xx. Past the
+ // limiter budget the denial may be 429 instead of 401 — both are fine,
+ // the invariant is "unauthenticated requests never succeed".
+ for (const [method, path] of PROTECTED_ROUTES) {
+ const res = await fetch(`${url}${path}`, {
+ method,
+ headers: method === "POST" ? { "Content-Type": "application/json" } : undefined,
+ body: method === "POST" ? "{}" : undefined,
+ });
+ await res.text();
+ assert.ok(
+ res.status === 401 || res.status === 429,
+ `${method} ${path} without a key returned ${res.status}; expected 401 or 429 (never a success)`,
+ );
+ assert.ok(res.status < 200 || res.status >= 300, `${method} ${path} must not succeed without a key`);
+ }
+ } finally {
+ await close();
+ }
+});
+
+test("SEC-3: an unrecognized stack timeframe is handled gracefully, not errored", async () => {
+ const { url, close } = await startTestServer();
+ try {
+ const res = await fetch(`${url}/api/stack?timeframe=__junk__`);
+ assert.equal(res.status, 200, "junk timeframe should be coerced and return 200, not error");
+ const body = (await res.json()) as Record;
+ assert.ok(Array.isArray(body.compute), "stack response should still carry layer arrays");
+ } finally {
+ await close();
+ }
+});
diff --git a/server/index.ts b/server/index.ts
index 41aade4..0a2a947 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -185,7 +185,9 @@ app.use((req, res, next) => {
{
port,
host: "0.0.0.0",
- reusePort: true,
+ // reusePort is a no-op on single-instance Replit and throws ENOTSUP on
+ // macOS, which breaks local `npm run dev`. Only enable it off-darwin.
+ reusePort: process.platform !== "darwin",
},
() => {
log(`serving on port ${port}`);
diff --git a/server/routes.ts b/server/routes.ts
index c3858e5..d5fdd9b 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -1759,7 +1759,12 @@ export async function registerRoutes(
// Stack endpoint - 8 layers, 10-min cache
app.get("/api/stack", async (req, res) => {
try {
- const timeframe = (req.query.timeframe as string) || "1D";
+ // Allowlist the timeframe and use the normalized value as the cache key.
+ // An arbitrary query string would otherwise be a permanent cache miss
+ // that fans out ~200 Yahoo calls per request (SEC-3).
+ const ALLOWED_TIMEFRAMES = ["1D", "5D", "1M"];
+ const requested = (req.query.timeframe as string) || "1D";
+ const timeframe = ALLOWED_TIMEFRAMES.includes(requested) ? requested : "1D";
const stockData = await getCachedStockData(timeframe);
const ccjCorrelationData = generateCCJCorrelationData();
@@ -2079,6 +2084,7 @@ export async function registerRoutes(
});
app.get("/api/newsletter/preview", async (req, res) => {
+ if (!requireAdmin(req, res)) return;
try {
const subscribers = loadSubscribers();
const subscriberCount = subscribers.length;
@@ -2150,7 +2156,11 @@ Sent to ${subscriberCount} subscribers. You're receiving this because you subscr
}
const previewUrl = `http://localhost:${process.env.PORT || 5000}/api/newsletter/preview`;
- const previewRes = await fetch(previewUrl);
+ // Preview is admin-gated (SEC-1); forward the server's own key on the
+ // internal call so the send path keeps working.
+ const previewRes = await fetch(previewUrl, {
+ headers: { "x-admin-key": process.env.ADMIN_API_KEY || "" },
+ });
const htmlTemplate = await previewRes.text();
let sent = 0;
@@ -2372,10 +2382,9 @@ Sent to ${subscriberCount} subscribers. You're receiving this because you subscr
}));
if (items.length > 0) {
newsCache = { items, timestamp: now };
- // Auto-scan for backlog headline updates + market constants
- scanNewsForBacklogUpdates(items).catch((e) => console.error("backlog scan error:", e));
- scanNewsForMarketConstants(items).catch((e) => console.error("market constants scan error:", e));
- maybeCheckLbnlEdition();
+ // News-driven dataset writes (backlog + uranium scanners, LBNL
+ // check) run only from the authenticated /api/admin/scan-news-now
+ // path. A public GET must never persist state (SEC-4).
return res.json(items);
}
}
@@ -2389,9 +2398,7 @@ Sent to ${subscriberCount} subscribers. You're receiving this because you subscr
const rssItems = await fetchRSSNews();
if (rssItems.length >= 3) {
newsCache = { items: rssItems, timestamp: now };
- scanNewsForBacklogUpdates(rssItems).catch((e) => console.error("backlog scan error:", e));
- scanNewsForMarketConstants(rssItems).catch((e) => console.error("market constants scan error:", e));
- maybeCheckLbnlEdition();
+ // Scanners moved to the authenticated /api/admin/scan-news-now (SEC-4).
return res.json(rssItems);
}
} catch (_e) {
@@ -2931,6 +2938,7 @@ Preferred-Languages: en
// Compose a tweet from a named template without posting. Use this to preview
// copy before scheduling. Returns the text + the template that was picked.
app.post("/api/social/generate", async (req, res) => {
+ if (!requireAdmin(req, res)) return;
const { template } = req.body || {};
const dayIdx = new Date().getDay();
const picked = template