Skip to content
Draft
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
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ server/data custody (the fragility map):

Documented, not to fix casually or silently:

- FAKE DATA STILL LIVE: /api/stack serves a Math.random CCJ/CEG correlation scatter, regenerated
each request (routes.ts ~426-466, ~1959-1985). The only fabricated data on the server;
contradicts the honest-data doctrine. Unmerged PR #2 removes it.
- Unmerged security hardening: PR #1 branch fix/m0-m1-truth-security (now conflicts with main)
holds five verified low/medium closes; cherry-pick commit da97234 rather than rebasing.
- ~~FAKE DATA~~ RESOLVED on fix/security-closes: the Math.random CCJ/CEG scatter was replaced
with real weekly closes (SRUUF physical-uranium proxy vs CCJ/CEG, server/correlation.ts,
6h cache; empty state instead of fallback data when Yahoo fails).
- ~~Unmerged security hardening~~ RESOLVED on fix/security-closes: da97234 (SEC-1..5 +
auth-boundary tests) cherry-picked cleanly. PR #1 can be closed once this merges.
- PR #2 (feat/real-metrics) proposes retiring the sentiment indices for a sourced scoreboard;
main kept the indices (served at /api/kpis, out of the social rotation). Owner decision pending.
- No CI (no .github/ at all). Tests and tsc run only when someone remembers.
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 @@ -119,6 +119,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 @@ -330,7 +342,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
61 changes: 37 additions & 24 deletions client/src/pages/TheStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ interface StockData {
}

interface CorrelationPoint {
uranium: number;
date: string;
uranium: number; // Sprott Physical Uranium Trust (SRUUF) weekly close, $/share
ccj: number;
}

Expand All @@ -57,8 +58,10 @@ interface StackData {
cryptoAIDC: StockData[];
etfsBenchmarks: StockData[];
correlation: CorrelationPoint[];
correlationCoeff: number;
cegCorrelationCoeff: number;
correlationCoeff: number | null;
cegCorrelationCoeff: number | null;
correlationWeeks?: number;
correlationProxy?: string;
}

function Sparkline({ data, color }: { data: number[] | undefined; color: string }) {
Expand Down Expand Up @@ -184,9 +187,11 @@ function StockCardSkeleton() {

const CustomScatterTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const date = payload[0]?.payload?.date;
return (
<div className="bg-card border border-card-border rounded-lg p-3 text-xs shadow-xl">
<p className="text-muted-foreground">Uranium Spot: <span className="text-foreground font-mono font-medium">${payload[0]?.value?.toFixed(2)}/lb</span></p>
{date && <p className="text-muted-foreground font-mono mb-1">week of {date}</p>}
<p className="text-muted-foreground">SRUUF: <span className="text-foreground font-mono font-medium">${payload[0]?.value?.toFixed(2)}</span></p>
<p className="text-muted-foreground">CCJ: <span className="text-foreground font-mono font-medium">${payload[1]?.value?.toFixed(2)}</span></p>
</div>
);
Expand Down Expand Up @@ -488,40 +493,48 @@ export default function TheStack() {
<div className="flex items-start justify-between gap-4 mb-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="font-semibold text-foreground">Uranium Spot vs. CCJ Correlation</h2>
<h2 className="font-semibold text-foreground">Physical Uranium vs. CCJ Correlation</h2>
<UITooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs leading-relaxed">CCJ is the largest public uranium miner with the highest direct spot price beta. CEG (utility) is influenced by electricity contracts and regulated returns. CCJ = commodity bet, CEG = infrastructure bet.</p>
<p className="text-xs leading-relaxed">The x-axis is the Sprott Physical Uranium Trust (SRUUF), a fund that holds physical U3O8 — the cleanest public proxy for spot without the circularity of a miners ETF. CCJ is the largest public uranium miner; CEG (utility) is influenced by electricity contracts and regulated returns. CCJ = commodity bet, CEG = infrastructure bet. Computed from observed weekly closes (Yahoo Finance), not modeled.</p>
</TooltipContent>
</UITooltip>
</div>
<p className="text-xs text-muted-foreground">52-week uranium spot price ($/lb) vs. CCJ stock price. Each dot = one week.</p>
<p className="text-xs text-muted-foreground">
Weekly closes, trailing year: Sprott Physical Uranium Trust (SRUUF) vs. CCJ. Each dot = one week{typeof data?.correlationWeeks === "number" && data.correlationWeeks > 0 ? ` (${data.correlationWeeks} observed)` : ""}.
</p>
</div>
<div className="flex items-center gap-6">
{data?.correlationCoeff !== undefined && (
<div className="text-right">
<p className="text-xs text-muted-foreground font-mono">CCJ Pearson r</p>
<p className="text-2xl font-bold font-mono text-[#F0A500]">{data.correlationCoeff.toFixed(3)}</p>
<p className="text-xs text-muted-foreground">
{data.correlationCoeff > 0.7 ? "Strong" : data.correlationCoeff > 0.4 ? "Moderate" : "Weak"} correlation
</p>
</div>
)}
{data?.cegCorrelationCoeff !== undefined && (
<div className="text-right">
<p className="text-xs text-muted-foreground font-mono">CEG Pearson r</p>
<p className="text-2xl font-bold font-mono text-foreground">{data.cegCorrelationCoeff.toFixed(3)}</p>
<p className="text-xs text-muted-foreground">Utility beta</p>
</div>
)}
<div className="text-right">
<p className="text-xs text-muted-foreground font-mono">CCJ Pearson r</p>
<p className="text-2xl font-bold font-mono text-[#F0A500]">
{typeof data?.correlationCoeff === "number" ? data.correlationCoeff.toFixed(3) : "—"}
</p>
<p className="text-xs text-muted-foreground">
{typeof data?.correlationCoeff !== "number"
? "no data"
: Math.abs(data.correlationCoeff) > 0.7 ? "Strong correlation" : Math.abs(data.correlationCoeff) > 0.4 ? "Moderate correlation" : "Weak correlation"}
</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground font-mono">CEG Pearson r</p>
<p className="text-2xl font-bold font-mono text-foreground">
{typeof data?.cegCorrelationCoeff === "number" ? data.cegCorrelationCoeff.toFixed(3) : "—"}
</p>
<p className="text-xs text-muted-foreground">Utility beta</p>
</div>
</div>
</div>

{isLoading ? (
<Skeleton className="h-48 w-full" />
) : !data?.correlation?.length ? (
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
Live weekly price data unavailable right now — nothing is shown rather than modeled data. Retries automatically.
</div>
) : (
<>
<ResponsiveContainer width="100%" height={260}>
Expand All @@ -535,7 +548,7 @@ export default function TheStack() {
tick={{ fill: "#6b7280", fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "rgba(255,255,255,0.08)" }}
label={{ value: "Uranium Spot ($/lb)", position: "insideBottom", offset: -10, fill: "#6b7280", fontSize: 11 }}
label={{ value: "Sprott Physical U Trust (SRUUF, $)", position: "insideBottom", offset: -10, fill: "#6b7280", fontSize: 11 }}
/>
<YAxis
dataKey="ccj"
Expand Down
138 changes: 138 additions & 0 deletions server/__tests__/auth-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
chart: (...args: unknown[]) => Promise<unknown>;
search?: (...args: unknown[]) => Promise<unknown>;
}
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<void> }> {
const app = express();
app.use(express.json());
const httpServer = createServer(app);
await registerRoutes(httpServer, app);
await new Promise<void>((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<void>((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<string, unknown>;
assert.ok(Array.isArray(body.compute), "stack response should still carry layer arrays");
} finally {
await close();
}
});
86 changes: 86 additions & 0 deletions server/__tests__/correlation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Locks the uranium correlation math. The scatter is built only from real
// observed weekly closes joined on bar date; r reads null (never a made-up
// number) when there is no usable overlap. This test exists because the card
// previously shipped Box-Muller synthetic data tuned to a target r.
import { test } from "node:test";
import assert from "node:assert/strict";
import { pearson, alignByDate, buildScatter, type WeeklyClose } from "../correlation";

test("pearson: perfectly linear series reads 1 / -1", () => {
const up = pearson([1, 2, 3, 4], [2, 4, 6, 8]);
const down = pearson([1, 2, 3, 4], [8, 6, 4, 2]);
assert.ok(up !== null && Math.abs(up - 1) < 1e-12);
assert.ok(down !== null && Math.abs(down + 1) < 1e-12);
});

test("pearson: refuses to fabricate a coefficient", () => {
assert.equal(pearson([1, 2], [2, 4]), null); // too short
assert.equal(pearson([5, 5, 5, 5], [1, 2, 3, 4]), null); // zero variance
assert.equal(pearson([], []), null);
});

test("pearson: known mixed series matches hand-computed value", () => {
// xs=[1,2,3,4,5], ys=[2,1,4,3,5]: num=8, denX=denY=sqrt(10) -> r = 0.8
const r = pearson([1, 2, 3, 4, 5], [2, 1, 4, 3, 5]);
assert.ok(r !== null && Math.abs(r - 0.8) < 1e-9);
});

test("alignByDate: joins on bar date, drops one-sided weeks instead of shifting", () => {
const a: WeeklyClose[] = [
{ date: "2026-06-01", close: 10 },
{ date: "2026-06-08", close: 11 },
{ date: "2026-06-15", close: 12 },
];
// b is missing 06-08 (OTC gap). Index pairing would wrongly pair 06-08 with 06-15.
const b: WeeklyClose[] = [
{ date: "2026-06-01", close: 100 },
{ date: "2026-06-15", close: 120 },
];
const aligned = alignByDate(a, b);
assert.deepEqual(aligned, [
{ date: "2026-06-01", a: 10, b: 100 },
{ date: "2026-06-15", a: 12, b: 120 },
]);
});

test("alignByDate: non-finite closes are excluded", () => {
const a: WeeklyClose[] = [
{ date: "2026-06-01", close: NaN },
{ date: "2026-06-08", close: 11 },
];
const b: WeeklyClose[] = [
{ date: "2026-06-01", close: 100 },
{ date: "2026-06-08", close: 110 },
];
assert.deepEqual(alignByDate(a, b), [{ date: "2026-06-08", a: 11, b: 110 }]);
});

test("buildScatter: points come from the observed joins and r from the same series", () => {
const proxy: WeeklyClose[] = [
{ date: "2026-05-25", close: 20.111 },
{ date: "2026-06-01", close: 21 },
{ date: "2026-06-08", close: 22 },
{ date: "2026-06-15", close: 23 },
];
const stock: WeeklyClose[] = [
{ date: "2026-05-25", close: 100.555 },
{ date: "2026-06-01", close: 105 },
{ date: "2026-06-08", close: 110 },
{ date: "2026-06-15", close: 115 },
];
const { points, r, weeks } = buildScatter(proxy, stock);
assert.equal(weeks, 4);
assert.equal(points.length, 4);
assert.deepEqual(points[0], { date: "2026-05-25", uranium: 20.11, ccj: 100.56 });
assert.ok(r !== null && r > 0.999); // near-linear input
});

test("buildScatter: empty overlap yields empty points and null r, not defaults", () => {
const { points, r, weeks } = buildScatter(
[{ date: "2026-06-01", close: 20 }],
[{ date: "2026-06-08", close: 100 }]
);
assert.deepEqual(points, []);
assert.equal(r, null);
assert.equal(weeks, 0);
});
Loading