Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions HOMEPAGE_HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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. |

---
Expand All @@ -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, ...)
```

---
Expand Down
14 changes: 9 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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'`
Expand All @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion client/src/pages/PowerMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

function pinRadius(powerMW: number): number {
if (powerMW >= 500) return 10;
if (powerMW >= 100) return 8;
Expand Down Expand Up @@ -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: `<div class="map-label">${truncName}</div>`,
html: `<div class="map-label">${escapeHtml(truncName)}</div>`,
className: "leaflet-label-icon",
iconSize: [120, 16],
iconAnchor: [-8, 20],
Expand Down
138 changes: 138 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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.
Loading