From 91e3fd84bcd634fc4dccf2b799bad0e8624a3364 Mon Sep 17 00:00:00 2001 From: dillonstreator Date: Thu, 2 Jul 2026 09:48:22 -0500 Subject: [PATCH 1/4] Address audit findings: viral loop, hot-path scans, data integrity, privacy Fixes the FATAL + MAJOR items from the full-spectrum audit. FATAL - F1 ship the viral loop: metadataBase + default and per-referrer OG images (next/og), a referrer hook card consuming /api/referrer, clickedRef recognize-and-celebrate, and an SSE live-join ticker wired to /api/live. - F2 keep reads off the graph: rank now counts the indexed cached_metrics only (no per-poll O(N*subtree) union scan); leaderboard bounds solo rows in SQL (ORDER BY reach DESC LIMIT) instead of streaming every human node into JS and slicing; leaderboard route gains an s-maxage cache header; statement_timeout set on both drivers. MAJOR - M1 cached max_depth stored absolute but read relative; subtract node depth at read so cached and live agree (verified equal on seeded data). - M2/M9 add /api/cron/reconcile (recompute cached_metrics from live subtree + reap expired tokens, CRON_SECRET-guarded) and a suspect flag: high-risk nodes are created+rendered but excluded from reach/leaderboard; ipShared crowd signal now computed instead of hardcoded false. - M3 exclude verified/account-linked nodes from fingerprint re-link to prevent node/account takeover via replayed or colliding visitorIds. - M4 drop @vercel/analytics so "no third-party trackers" is true; add /api/auth/delete (GDPR erasure keyed by wh_lid cookie) + retention copy. - M5 gate /api/me on wh_lid cookie ownership so the public share code no longer exposes every linked device's coordinates. - M6 await magic send and delete the token row on failure so a send outage no longer locks the user out for the TTL. - M7 surface registration failures with a retry banner and auto-recover identity_mismatch by clearing the cookie and retrying once. - M8 provision Postgres in CI and run migrations so integration tests actually execute. Verified: typecheck, build, 24/24 tests (incl. integration) against local Postgres; OG PNGs render 1200x630; /api/me returns 200 owner / 403 wrong / 401 none; cached==live metrics after reconcile. Co-Authored-By: Claude Fable 5 --- .env.example | 5 + .github/workflows/ci.yml | 18 ++ package.json | 1 - pnpm-lock.yaml | 37 ---- src/app/[code]/opengraph-image.tsx | 65 +++++++ src/app/about/page.tsx | 22 ++- src/app/api/auth/delete/route.ts | 36 ++++ src/app/api/auth/magic/route.ts | 21 ++- src/app/api/cron/reconcile/route.ts | 36 ++++ src/app/api/leaderboard/route.ts | 8 +- src/app/api/me/[code]/route.ts | 24 ++- src/app/api/node/route.ts | 30 +++- src/app/layout.tsx | 13 +- src/app/opengraph-image.tsx | 50 ++++++ src/components/App.tsx | 19 +- src/components/DeleteDataButton.tsx | 66 +++++++ src/components/sections/Hero.tsx | 4 + src/components/sections/LiveTicker.tsx | 49 +++++ src/components/sections/ReferrerHook.tsx | 54 ++++++ src/db/graph.ts | 216 ++++++++++++++++------- src/db/index.ts | 24 ++- src/db/migrations/0004_suspect_flag.sql | 5 + src/db/migrations/meta/_journal.json | 7 + src/db/reads.ts | 7 +- src/db/schema.ts | 1 + src/db/seed-me.ts | 3 + src/db/seed.ts | 1 + src/lib/client-identity.ts | 21 ++- src/lib/codes.ts | 5 + src/lib/queries.ts | 25 +++ vercel.json | 8 +- 31 files changed, 748 insertions(+), 133 deletions(-) create mode 100644 src/app/[code]/opengraph-image.tsx create mode 100644 src/app/api/auth/delete/route.ts create mode 100644 src/app/api/cron/reconcile/route.ts create mode 100644 src/app/opengraph-image.tsx create mode 100644 src/components/DeleteDataButton.tsx create mode 100644 src/components/sections/LiveTicker.tsx create mode 100644 src/components/sections/ReferrerHook.tsx create mode 100644 src/db/migrations/0004_suspect_flag.sql diff --git a/.env.example b/.env.example index 13ecb4c..76ec472 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ DATABASE_URL="postgres://worldhello:worldhello@localhost:5433/worldhello" KV_REST_API_URL="" KV_REST_API_TOKEN="" +# Secret guarding the scheduled maintenance endpoint (/api/cron/reconcile). +# Vercel Cron sends it as `Authorization: Bearer $CRON_SECRET`. If unset the +# endpoint refuses all requests (reconciliation + token reaping won't run). +CRON_SECRET="" + # ── Email (magic-link) ── falls back in this order: Resend → SMTP → dev console. # 1. Resend (preferred in prod) RESEND_API_KEY="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 213a337..cab8ff0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,21 @@ jobs: DATABASE_URL: postgres://ci:ci@localhost:5432/ci NEXT_PUBLIC_BASE_URL: http://localhost:3000 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ci -d ci" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: - uses: actions/checkout@v4 @@ -28,6 +43,9 @@ jobs: - run: pnpm install --frozen-lockfile + # Apply schema (ltree ext, triggers, indexes) so integration tests have a DB. + - run: pnpm db:migrate + - run: pnpm test - run: pnpm typecheck diff --git a/package.json b/package.json index 0c7a85f..4524614 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@tanstack/react-query": "^5.101.0", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", - "@vercel/analytics": "^2.0.1", "botid": "^1.5.11", "drizzle-orm": "^0.45.2", "isbot": "^5.1.43", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ca15d0..f87ca15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@upstash/redis': specifier: ^1.38.0 version: 1.38.0 - '@vercel/analytics': - specifier: ^2.0.1 - version: 2.0.1(next@16.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) botid: specifier: ^1.5.11 version: 1.5.11(next@16.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) @@ -1180,35 +1177,6 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vercel/analytics@2.0.1': - resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} - peerDependencies: - '@remix-run/react': ^2 - '@sveltejs/kit': ^1 || ^2 - next: '>= 13' - nuxt: '>= 3' - react: ^18 || ^19 || ^19.0.0-rc - svelte: '>= 4' - vue: ^3 - vue-router: ^4 - peerDependenciesMeta: - '@remix-run/react': - optional: true - '@sveltejs/kit': - optional: true - next: - optional: true - nuxt: - optional: true - react: - optional: true - svelte: - optional: true - vue: - optional: true - vue-router: - optional: true - '@vitest/expect@3.2.6': resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} @@ -2867,11 +2835,6 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.4 - '@vercel/analytics@2.0.1(next@16.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': - optionalDependencies: - next: 16.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - '@vitest/expect@3.2.6': dependencies: '@types/chai': 5.2.3 diff --git a/src/app/[code]/opengraph-image.tsx b/src/app/[code]/opengraph-image.tsx new file mode 100644 index 0000000..fbaba4d --- /dev/null +++ b/src/app/[code]/opengraph-image.tsx @@ -0,0 +1,65 @@ +import { ImageResponse } from "next/og"; +import { referrerCard } from "@/db/reads"; +import { fmtCompact } from "@/lib/format"; + +export const runtime = "nodejs"; +export const alt = "Continue the chain on worldhello"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +/** + * Per-referrer share card (the hook, DESIGN §2). A link shared as worldhello.io/ + * unfurls with the referrer's reach so the recipient sees a live number, not a generic + * banner — the whole point of the referral loop (was FATAL F1). + */ +export default async function OG({ params }: { params: Promise<{ code: string }> }) { + const { code } = await params; + const card = await referrerCard(code).catch(() => null); + + const stat = (value: string, label: string) => ( +
+
{value}
+
{label}
+
+ ); + + return new ImageResponse( + ( +
+
+ SOMEONE STARTED A CHAIN +
+ {card ? ( +
+ {stat(fmtCompact(card.reach), "people reached")} + {stat(fmtCompact(card.countries), "countries")} + {stat(fmtCompact(card.maxDepth), "degrees deep")} +
+ ) : ( +
+ Join the chain +
+ )} +
+ Continue the chain → +
+
+ Anonymous · no sign-up +
+
+ ), + size, + ); +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index a2f7150..e1e25d9 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { siteHost } from "@/lib/site"; +import DeleteDataButton from "@/components/DeleteDataButton"; const site = siteHost(); @@ -79,8 +80,8 @@ export default function About() { opt-in. - There are no third-party ad pixels, no cross-site trackers, no behavioral profiling. The only - data captured is the shape of the referral graph itself. + No third-party ad pixels, no cross-site trackers, no behavioral profiling, and no analytics + SDK. The only data captured is the shape of the referral graph itself. Each node stores only what the visualization needs: a share code, a referrer link, a coarse @@ -98,6 +99,23 @@ export default function About() { +
+

+ Personal signals — the keyed hashes of your fingerprint and IP, your user agent, and bot + verdicts — live in a separate append-only table used only for abuse defense, and are dropped + on the schedule below rather than kept forever. Your anonymous node (a share code, a referral + edge, a coarse location, and a few counts) persists as long as the web exists, because + descendants' chains hang off it. +

+

+ You can erase your device's personal data at any time — no email, no account, no support + ticket. It's keyed to your browser, so do it from the device you want scrubbed. +

+
+ +
+
+

worldhello is built in the open. The privacy claims above are verifiable because the code that diff --git a/src/app/api/auth/delete/route.ts b/src/app/api/auth/delete/route.ts new file mode 100644 index 0000000..8b07357 --- /dev/null +++ b/src/app/api/auth/delete/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { scrubDeviceData } from "@/db/graph"; +import { logApiReject } from "@/lib/api-log"; + +export const runtime = "nodejs"; + +const COOKIE = "wh_lid"; + +/** + * Data-deletion request (GDPR/CCPA erasure). Keyed by the wh_lid cookie — the only + * handle the anonymous owner holds. Scrubs this device's personal data (hashed + * fingerprint, coarse location, audit signals) and detaches it from any account, + * dropping the account row if it's left empty. + * + * The structural graph node is kept but fully anonymized: its share code and + * referral edge stay so descendants' chains don't break, but nothing personal + * remains. Removing the row entirely would orphan every child's ltree path. + */ +export async function POST(req: NextRequest) { + const localId = req.cookies.get(COOKIE)?.value; + if (!localId) { + logApiReject("auth/delete", "no_device", { hasCookie: false }); + return NextResponse.json({ error: "no_device" }, { status: 401 }); + } + + const scrubbed = await scrubDeviceData(localId); + if (!scrubbed) { + logApiReject("auth/delete", "device_missing"); + return NextResponse.json({ error: "device_missing" }, { status: 404 }); + } + + // Clear the identity cookie so the browser starts fresh next visit. + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE, "", { path: "/", maxAge: 0 }); + return res; +} diff --git a/src/app/api/auth/magic/route.ts b/src/app/api/auth/magic/route.ts index 807527c..3ae5910 100644 --- a/src/app/api/auth/magic/route.ts +++ b/src/app/api/auth/magic/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { eq } from "drizzle-orm"; import { db } from "@/db"; import { magicTokens } from "@/db/schema"; import { newNonce, signNonce, MAGIC_TTL_MS } from "@/lib/token"; @@ -52,13 +53,19 @@ export async function POST(req: NextRequest) { const host = siteHost(req.nextUrl.origin); const link = `${base}/api/auth/verify?token=${encodeURIComponent(token)}`; - await sendMail({ - to: email, - subject: `Verify your ${host} account`, - html: `

Click below to verify your account on ${host}:

Verify my account

Confirm on the same device that requested this email. Linked devices stay connected. Expires in 30 minutes. If you didn't request this, ignore it.

`, - }).catch((err) => { - console.error("[auth/magic] send failed:", err); - }); + try { + await sendMail({ + to: email, + subject: `Verify your ${host} account`, + html: `

Click below to verify your account on ${host}:

Verify my account

Confirm on the same device that requested this email. Linked devices stay connected. Expires in 30 minutes. If you didn't request this, ignore it.

`, + }); + } catch (err) { + // Send failed — drop the token so pendingMagicToken doesn't silently + // lock the user out for MAGIC_TTL while the UI says "check your inbox" + // (was MAJOR M6). The user can retry immediately. + console.error("[auth/magic] send failed, dropping token:", err); + await db.delete(magicTokens).where(eq(magicTokens.nonce, nonce)).catch(() => {}); + } } } } else { diff --git a/src/app/api/cron/reconcile/route.ts b/src/app/api/cron/reconcile/route.ts new file mode 100644 index 0000000..05033af --- /dev/null +++ b/src/app/api/cron/reconcile/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { reconcileMetrics, reapExpiredTokens } from "@/db/graph"; +import { logApiError, logApiReject } from "@/lib/api-log"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +/** + * Scheduled maintenance (Vercel Cron — see vercel.json): + * - reconcile cached_metrics back to the live subtree (repairs M2 drift) + * - reap consumed/expired magic + link tokens + * + * Vercel Cron sends `Authorization: Bearer $CRON_SECRET`; we also accept it manually. + * Without CRON_SECRET set the endpoint refuses (never publicly runnable). + */ +export async function GET(req: NextRequest) { + const secret = process.env.CRON_SECRET; + if (!secret) { + logApiReject("cron/reconcile", "no_secret"); + return NextResponse.json({ error: "not_configured" }, { status: 503 }); + } + if (req.headers.get("authorization") !== `Bearer ${secret}`) { + logApiReject("cron/reconcile", "unauthorized"); + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + try { + const reconciled = await reconcileMetrics(); + await reapExpiredTokens(); + return NextResponse.json({ ok: true, reconciled }); + } catch (err) { + logApiError("cron/reconcile", "failed", err); + return NextResponse.json({ error: "failed" }, { status: 500 }); + } +} diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index baa2fbe..5e1865f 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -6,5 +6,11 @@ export const dynamic = "force-dynamic"; export async function GET() { const rows = await leaderboard(20); - return NextResponse.json({ rows }); + // Same cache posture as /api/globe — leaderboard tolerates seconds of staleness and + // is polled by every client; served off the CDN snapshot so poll fan-out can't + // hammer Postgres (DESIGN §6.5 L4 reads-off-cache). + return NextResponse.json( + { rows }, + { headers: { "cache-control": "public, s-maxage=15, stale-while-revalidate=45" } }, + ); } diff --git a/src/app/api/me/[code]/route.ts b/src/app/api/me/[code]/route.ts index 875318c..5cf2a2c 100644 --- a/src/app/api/me/[code]/route.ts +++ b/src/app/api/me/[code]/route.ts @@ -1,17 +1,37 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { meBundle } from "@/db/reads"; +import { codeOwnedBy } from "@/db/graph"; +import { logApiReject } from "@/lib/api-log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const Code = z.string().min(4).max(16); +const COOKIE = "wh_lid"; -export async function GET(_req: Request, { params }: { params: Promise<{ code: string }> }) { +/** + * Private dashboard bundle. `code` is the PUBLIC share code, so this must NOT be + * readable by anyone holding a share link — it exposes every linked device's + * coordinates, the referrer's location, and internal arcs (was MAJOR M5). Gate on + * the caller's wh_lid cookie owning (or sharing an account with) that code's node. + */ +export async function GET(req: NextRequest, { params }: { params: Promise<{ code: string }> }) { const { code } = await params; if (!Code.safeParse(code).success) { return NextResponse.json({ error: "bad_code" }, { status: 400 }); } + + const localId = req.cookies.get(COOKIE)?.value; + if (!localId) { + logApiReject("me", "no_device", { hasCookie: false }); + return NextResponse.json({ error: "no_device" }, { status: 401 }); + } + if (!(await codeOwnedBy(code, localId))) { + logApiReject("me", "forbidden"); + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + // One DB round-trip (was ~5): node geo + parent + children + metrics + rank. const bundle = await meBundle(code); if (!bundle) return NextResponse.json({ error: "not_found" }, { status: 404 }); diff --git a/src/app/api/node/route.ts b/src/app/api/node/route.ts index 6ceeaf8..bc74289 100644 --- a/src/app/api/node/route.ts +++ b/src/app/api/node/route.ts @@ -9,9 +9,10 @@ import { nodeByCode, metricsForNode, bumpAncestors, + ipHasCrowd, type CreatedNode, } from "@/db/graph"; -import { newCode } from "@/lib/codes"; +import { newCode, SUSPECT_RISK_THRESHOLD } from "@/lib/codes"; import { isHuman, resolveClass } from "@/lib/classify"; import { geoFromHeaders, clientIp } from "@/lib/geo"; import { hashKeyed } from "@/lib/crypto"; @@ -110,6 +111,20 @@ export async function POST(req: NextRequest) { const geo = geoFromHeaders(h); + // Crowd signal: many DISTINCT localIds recently on this IP = a shared network + // (conference wifi / CGNAT), which LOWERS risk (DESIGN §6.6). A single localId + // hammering one IP does not trip this — that stays high-risk. + const ipShared = await ipHasCrowd(ipHash, localId); + const risk = riskScore({ + baseRisk: classified.baseRisk, + ephemeral, + hasFingerprint: !!fpHash, + ipShared, + }); + // Suspect nodes are still created + rendered (dimmed), just excluded from + // reach/leaderboard — so a same-fingerprint mint farm can't inflate the board. + const suspect = isHuman(nodeClass) && risk >= SUSPECT_RISK_THRESHOLD; + // ── Create node (single-statement ltree path + depth + cycle/depth guard). ── let created: CreatedNode; try { @@ -120,6 +135,7 @@ export async function POST(req: NextRequest) { referrerId, class: nodeClass, ephemeral, + suspect, country: geo.country, lat: geo.lat, lng: geo.lng, @@ -144,7 +160,10 @@ export async function POST(req: NextRequest) { logApiError("node", "create failed", e); throw e; } - if (isHuman(nodeClass)) { + // Only non-suspect humans bump ancestor reach + seed a cached_metrics row. Suspect + // nodes get no cache row → naturally absent from reach, rank denominator, and + // leaderboard, without a separate exclusion filter on those read paths. + if (isHuman(nodeClass) && !suspect) { await bumpAncestors(created, geo.country); } @@ -158,12 +177,7 @@ export async function POST(req: NextRequest) { incognitoGuess: ephemeral, referer: refererHost(referer), // origin only — never store full URL/query (privacy) src: src ?? null, - riskScore: riskScore({ - baseRisk: classified.baseRisk, - ephemeral, - hasFingerprint: !!fpHash, - ipShared: false, - }), + riskScore: risk, }); const metrics = await metricsForNode(created.id); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1aac758..2361b84 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,11 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; import Providers from "@/components/Providers"; -import { siteHost } from "@/lib/site"; +import { siteHost, siteBaseUrl } from "@/lib/site"; const site = siteHost(); +const baseUrl = siteBaseUrl(); const geistSans = Geist({ variable: "--font-geist-sans", @@ -18,6 +18,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { + metadataBase: baseUrl ? new URL(baseUrl) : undefined, title: site ? `${site} — watch your link reach the world` : "watch your link reach the world", description: "Share one link and watch it travel the globe in real time. Anonymous, no sign-up.", @@ -25,6 +26,13 @@ export const metadata: Metadata = { title: site || "watch your link reach the world", description: "Share one link, watch your reach spread across the world.", type: "website", + images: ["/opengraph-image"], + }, + twitter: { + card: "summary_large_image", + title: site || "watch your link reach the world", + description: "Share one link, watch your reach spread across the world.", + images: ["/opengraph-image"], }, }; @@ -41,7 +49,6 @@ export default function RootLayout({ > {children} - ); diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..0143b93 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,50 @@ +import { ImageResponse } from "next/og"; + +export const runtime = "nodejs"; +export const alt = "worldhello — watch your link reach the world"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +/** Default share card. The per-referrer card lives at /[code]/opengraph-image. */ +export default function OG() { + return new ImageResponse( + ( +
+
+ ONE LINK · ONE GROWING WEB +
+
+ Watch your link +
+
+ reach the world. +
+
+ Anonymous · no sign-up · see it travel in real time +
+
+ ), + size, + ); +} diff --git a/src/components/App.tsx b/src/components/App.tsx index 7971064..3075858 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -9,6 +9,7 @@ import { type VerifyFailureReason, } from "@/lib/verify-feedback"; import Header from "./sections/Header"; +import ReferrerHook from "./sections/ReferrerHook"; import Hero from "./sections/Hero"; import Network from "./sections/Network"; import ShareSection from "./sections/ShareSection"; @@ -34,7 +35,7 @@ export default function App({ }, []); const queryClient = useQueryClient(); - const { data: node } = useRegister(refCode); + const { data: node, isError: registerFailed, isLoading: registering } = useRegister(refCode); const { data: me } = useMe(node?.code); const linkAttempted = useRef(false); const verifyAttempted = useRef(false); @@ -107,7 +108,23 @@ export default function App({

)} + {registerFailed && !registering && ( +
+

+ Couldn't start your web — this can happen in a private window or with an + ad-blocker.{" "} + +

+
+ )}
+ {node && } diff --git a/src/components/DeleteDataButton.tsx b/src/components/DeleteDataButton.tsx new file mode 100644 index 0000000..1038bd0 --- /dev/null +++ b/src/components/DeleteDataButton.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; + +/** Erase this device's data (GDPR). Keyed server-side by the wh_lid cookie. */ +export default function DeleteDataButton() { + const [state, setState] = useState<"idle" | "confirm" | "working" | "done" | "error">("idle"); + + const del = async () => { + setState("working"); + try { + const r = await fetch("/api/auth/delete", { method: "POST" }); + setState(r.ok ? "done" : "error"); + } catch { + setState("error"); + } + }; + + if (state === "done") { + return

Your device data was erased. Reload to start fresh.

; + } + + const working = state === "working"; + const showConfirm = state === "confirm" || working; + + return ( +
+ {!showConfirm ? ( + + ) : ( +
+

+ This scrubs your fingerprint hash, coarse location, and audit signals, and detaches this + device from any linked account. Your anonymous node stays so people you referred keep their + chain — but nothing personal remains. This can't be undone. +

+
+ + +
+
+ )} + {state === "error" &&

Couldn't erase — try again.

} +
+ ); +} diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index 81b993a..40fa9b9 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import type { NodeResponse } from "@/lib/client-identity"; import type { MeDetail } from "@/lib/types"; import { fmtCompact, fmtRank } from "@/lib/format"; +import LiveTicker from "./LiveTicker"; const Globe = dynamic(() => import("../Globe"), { ssr: false }); @@ -112,6 +113,9 @@ export default function Hero({ node, me }: { node: NodeResponse | null; me: MeDe

drag the globe to explore · auto-rotating

+
+ +
); } diff --git a/src/components/sections/LiveTicker.tsx b/src/components/sections/LiveTicker.tsx new file mode 100644 index 0000000..5a3ee6a --- /dev/null +++ b/src/components/sections/LiveTicker.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const REGION = typeof Intl !== "undefined" ? new Intl.DisplayNames(["en"], { type: "region" }) : null; + +function countryName(code: string | null): string { + if (!code) return "somewhere"; + try { + return REGION?.of(code) ?? code; + } catch { + return code; + } +} + +/** + * Live-join ticker (DESIGN §9). Consumes the SSE stream from /api/live so the globe + * feels alive — "someone in Lagos just joined." Without this consumer the endpoint + * was dead weight (was FATAL F1). + */ +export default function LiveTicker() { + const [msg, setMsg] = useState(null); + + useEffect(() => { + const es = new EventSource("/api/live"); + es.addEventListener("join", (e) => { + try { + const { country } = JSON.parse((e as MessageEvent).data) as { country: string | null }; + setMsg(`Someone in ${countryName(country)} just joined`); + } catch { + /* ignore malformed frame */ + } + }); + es.onerror = () => { + // Browser auto-reconnects; nothing to do. Close on unmount below. + }; + return () => es.close(); + }, []); + + if (!msg) return null; + return ( +
+ + + {msg} + +
+ ); +} diff --git a/src/components/sections/ReferrerHook.tsx b/src/components/sections/ReferrerHook.tsx new file mode 100644 index 0000000..1b80523 --- /dev/null +++ b/src/components/sections/ReferrerHook.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useReferrerCard } from "@/lib/queries"; +import { fmtCompact } from "@/lib/format"; + +/** + * The hook (DESIGN §2). On a ?ref landing, the referrer's live stats appear first — + * the recipient sees a real number and a chain to continue, not a generic banner. + * When `clickedRef` is set, the visitor is an existing node clicking someone in + * their own tree, so we celebrate ownership instead of pitching a join. + */ +export default function ReferrerHook({ + refCode, + clickedRef, +}: { + refCode: string | null; + clickedRef?: string | null; +}) { + const code = clickedRef ?? refCode; + const { data: card } = useReferrerCard(code ?? null); + if (!code || !card) return null; + + const celebrate = !!clickedRef; + + return ( +
+
+ {celebrate ? ( + <> +
you started this chain
+

+ That link is already in your web — {fmtCompact(card.reach)} people deep. +

+

+ Everyone who joins through it counts toward your reach. Keep sharing yours. +

+ + ) : ( + <> +
continue the chain
+

+ Someone reached {fmtCompact(card.reach)} people across {fmtCompact(card.countries)}{" "} + {card.countries === 1 ? "country" : "countries"} + {card.verified && } +

+

+ You just joined their web. Now grow your own — share your link below. +

+ + )} +
+
+ ); +} diff --git a/src/db/graph.ts b/src/db/graph.ts index c8a7784..a038dc9 100644 --- a/src/db/graph.ts +++ b/src/db/graph.ts @@ -17,6 +17,7 @@ export type CreateNodeInput = { referrerId: number | null; class: string; ephemeral: boolean; + suspect: boolean; country: string | null; lat: number | null; lng: number | null; @@ -54,10 +55,10 @@ export async function createNode(input: CreateNodeInput): Promise { // path + depth are set by the BEFORE INSERT trigger (set_node_path) — a sibling // CTE cannot UPDATE a row another CTE just inserted (same snapshot). const rows = (await db.execute(sql` - INSERT INTO nodes (code, local_id, fingerprint, referrer_id, class, ephemeral, country, lat, lng) + INSERT INTO nodes (code, local_id, fingerprint, referrer_id, class, ephemeral, suspect, country, lat, lng) VALUES ( ${input.code}, ${input.localId}, ${input.fingerprint}, ${input.referrerId}, - ${input.class}, ${input.ephemeral}, ${input.country}, ${input.lat}, ${input.lng} + ${input.class}, ${input.ephemeral}, ${input.suspect}, ${input.country}, ${input.lat}, ${input.lng} ) RETURNING id, code, depth, path::text AS path, referrer_id AS "referrerId"; `)) as unknown as { rows: CreatedNode[] }; @@ -138,10 +139,16 @@ export async function resolveNode( if (byLocal.rows?.[0]) return byLocal.rows[0]; if (fingerprint) { + // Fingerprint re-link is a recovery path for cleared storage (DESIGN §2), but + // it re-keys a node's local_id onto the caller — so it must never hand over a + // node that guards a verified badge or an account device group. FingerprintJS + // OSS visitorIds collide across identical device models and can be replayed; + // for those nodes the magic link is the only sanctioned recovery. const byFp = (await db.execute(sql` SELECT id, code, referrer_id AS "referrerId", local_id AS "localId" FROM nodes WHERE fingerprint = ${fingerprint} AND class = 'human' + AND account_id IS NULL AND verified = false ORDER BY created_at ASC LIMIT 1; `)) as unknown as { rows: ResolvedNode[] }; if (byFp.rows?.[0]) return byFp.rows[0]; @@ -164,6 +171,55 @@ export async function relinkNodeLocalId(nodeId: number, newLocalId: string): Pro return !!updated.rows?.[0]; } +/** + * Crowd check for risk scoring (DESIGN §6.6): does this IP show many DISTINCT + * identities recently? Many distinct fingerprints on one IP = a shared network + * (café / conference / CGNAT) — the in-person viral scenario — which LOWERS risk. + * One identity repeating on an IP does NOT trip this. `_localId` documents that + * localId is deliberately not the key here (it's client-minted and rotatable). + */ +export async function ipHasCrowd(ipHash: string, _localId: string): Promise { + const r = (await db.execute(sql` + SELECT COUNT(DISTINCT fingerprint)::int AS n + FROM node_signals + WHERE ip_hash = ${ipHash} + AND fingerprint IS NOT NULL + AND seen_at > now() - interval '1 hour'; + `)) as unknown as { rows: { n: number }[] }; + return Number(r.rows?.[0]?.n ?? 0) >= 5; +} + +/** + * Erase a device's personal data (GDPR). Nulls hashed fingerprint + coarse geo on + * the node, deletes its append-only signal rows, detaches it from any account, and + * drops that account if it's now empty. The anonymous graph node (code + edge) + * survives so descendants' ltree paths stay intact. Returns false if no such node. + */ +export async function scrubDeviceData(localId: string): Promise { + const node = (await db.execute(sql` + SELECT id, account_id AS "accountId" FROM nodes WHERE local_id = ${localId} LIMIT 1; + `)) as unknown as { rows: { id: number; accountId: number | null }[] }; + const row = node.rows?.[0]; + if (!row) return false; + + await db.execute(sql`DELETE FROM node_signals WHERE node_id = ${row.id};`); + await db.execute(sql` + UPDATE nodes + SET fingerprint = NULL, country = NULL, lat = NULL, lng = NULL, + account_id = NULL, verified = false + WHERE id = ${row.id}; + `); + + if (row.accountId != null) { + await db.execute(sql` + DELETE FROM accounts a + WHERE a.id = ${row.accountId} + AND NOT EXISTS (SELECT 1 FROM nodes n WHERE n.account_id = a.id); + `); + } + return true; +} + /** Look up a node id by its share code (the ?ref target). */ export async function nodeByCode( code: string, @@ -174,6 +230,26 @@ export async function nodeByCode( return r.rows?.[0] ?? null; } +/** + * Ownership check for the private dashboard (M5): the device holding `localId` + * owns `code` when it IS that node, or shares a linked account with it. The share + * code alone is not enough — it's public. + */ +export async function codeOwnedBy(code: string, localId: string): Promise { + const r = (await db.execute(sql` + SELECT 1 + FROM nodes target + JOIN nodes caller ON caller.local_id = ${localId} + WHERE target.code = ${code} + AND ( + target.local_id = ${localId} + OR (target.account_id IS NOT NULL AND target.account_id = caller.account_id) + ) + LIMIT 1; + `)) as unknown as { rows: unknown[] }; + return !!r.rows?.[0]; +} + export type Metrics = { reach: number; direct: number; @@ -194,7 +270,7 @@ export async function subtreeMetrics(nodeId: number): Promise { COALESCE(MAX(n.depth) - (SELECT depth FROM me), 0) AS "maxDepth", COUNT(DISTINCT n.country) FILTER (WHERE n.country IS NOT NULL) AS countries FROM nodes n, me - WHERE n.path <@ me.path AND n.class = 'human'; + WHERE n.path <@ me.path AND n.class = 'human' AND n.suspect = false; `)) as unknown as { rows: Metrics[] }; const row = r.rows?.[0]; return { @@ -222,7 +298,7 @@ export async function accountMetrics(accountId: number): Promise { SELECT DISTINCT n.id, n.country, n.depth, n.referrer_id FROM nodes n INNER JOIN account_nodes an ON n.path <@ an.path - WHERE n.class = 'human' + WHERE n.class = 'human' AND n.suspect = false ), min_depth AS ( SELECT MIN(depth) AS d FROM account_nodes @@ -249,10 +325,19 @@ export async function accountMetrics(accountId: number): Promise { /** Read denormalized per-node metrics (fast path for polls and leaderboard). */ export async function cachedMetricsForNode(nodeId: number): Promise { + // cached_metrics.max_depth is stored ABSOLUTE (the deepest descendant's tree + // depth); the headline metric is depth RELATIVE to this node (how far the chain + // travelled past you). Subtract the node's own depth here so the cached path and + // the live subtreeMetrics path (which already computes relative) agree. const r = (await db.execute(sql` - SELECT reach, direct, max_depth AS "maxDepth", countries - FROM cached_metrics - WHERE node_id = ${nodeId} + SELECT + m.reach, + m.direct, + GREATEST(m.max_depth - n.depth, 0) AS "maxDepth", + m.countries + FROM cached_metrics m + INNER JOIN nodes n ON n.id = m.node_id + WHERE m.node_id = ${nodeId} LIMIT 1; `)) as unknown as { rows: Metrics[] }; const row = r.rows?.[0]; @@ -293,65 +378,22 @@ export async function rankForNodeId( } /** - * Rank a reach score against all identities: solo nodes use cached reach; - * multi-device accounts use one union reach (DESIGN §5.5). + * Rank a reach score against the field. Counts every solo/leaf node's cached reach + * off the indexed `cached_metrics_reach_idx` (COUNT WHERE reach > $1) — no graph + * walk, so this stays cheap on the polled hot path (DESIGN §5 leaderboard plan). + * + * Tradeoff: a multi-device account is counted once per device rather than once per + * union, so a heavily-linked account is slightly over-weighted in `total`. That's a + * bounded, small error against keeping every /api/me poll off an O(N·subtree) scan; + * exact union rank is not worth a full-graph query 2×/min/user (was FATAL F2). */ export async function rankForReach( reach: number, ): Promise<{ rank: number; percentile: number } | null> { const r = (await db.execute(sql` - WITH linked AS ( - SELECT account_id - FROM nodes - WHERE account_id IS NOT NULL AND class = 'human' - GROUP BY account_id - HAVING COUNT(*) > 1 - ), - account_nodes AS ( - SELECT n.account_id, n.id, n.path, n.depth - FROM nodes n - INNER JOIN linked l ON l.account_id = n.account_id - WHERE n.class = 'human' - ), - union_nodes AS ( - SELECT DISTINCT an.account_id, n.id, n.depth - FROM account_nodes an - INNER JOIN nodes n ON n.path <@ an.path AND n.class = 'human' - ), - device_ids AS ( - SELECT account_id, id FROM account_nodes - ), - min_depth AS ( - SELECT account_id, MIN(depth) AS d FROM account_nodes GROUP BY account_id - ), - account_scores AS ( - SELECT - u.account_id, - COUNT(*) FILTER (WHERE NOT EXISTS ( - SELECT 1 FROM device_ids d - WHERE d.account_id = u.account_id AND d.id = u.id - ))::int AS reach - FROM union_nodes u - GROUP BY u.account_id - ), - solo_scores AS ( - SELECT m.reach::int AS reach - FROM cached_metrics m - INNER JOIN nodes n ON n.id = m.node_id - WHERE n.class = 'human' - AND ( - n.account_id IS NULL - OR n.account_id NOT IN (SELECT account_id FROM linked) - ) - ), - all_scores AS ( - SELECT reach FROM solo_scores - UNION ALL - SELECT reach FROM account_scores - ) SELECT - (SELECT COUNT(*)::int FROM all_scores WHERE reach > ${reach}) + 1 AS rank, - (SELECT COUNT(*)::int FROM all_scores) AS total; + (SELECT COUNT(*)::int FROM cached_metrics WHERE reach > ${reach}) + 1 AS rank, + (SELECT COUNT(*)::int FROM cached_metrics) AS total; `)) as unknown as { rows: { rank: number; total: number }[] }; const row = r.rows?.[0]; if (!row) return null; @@ -430,10 +472,16 @@ export async function linkedAccountLeaderRows(): Promise { })); } -/** Leaderboard rows for solo devices (no linked siblings on the account). */ -export async function soloLeaderRows(): Promise { +/** + * Leaderboard rows for solo devices (no linked siblings on the account). + * Bounded by `limit` on the indexed `cached_metrics_reach_idx` so the whole node + * table never streams into the app (was FATAL F2 — the JS-side slice pulled every + * human row). Over-fetch past the display limit to leave room for the linked-account + * rows the caller merges in. + */ +export async function soloLeaderRows(limit = 40): Promise { const r = (await db.execute(sql` - SELECT n.code, m.reach, m.max_depth AS "maxDepth", m.countries, n.verified, n.country + SELECT n.code, m.reach, GREATEST(m.max_depth - n.depth, 0) AS "maxDepth", m.countries, n.verified, n.country FROM cached_metrics m INNER JOIN nodes n ON n.id = m.node_id WHERE n.class = 'human' @@ -443,7 +491,9 @@ export async function soloLeaderRows(): Promise { SELECT 1 FROM nodes n2 WHERE n2.account_id = n.account_id AND n2.id <> n.id AND n2.class = 'human' ) - ); + ) + ORDER BY m.reach DESC + LIMIT ${limit}; `)) as unknown as { rows: LeaderIdentityRow[] }; return (r.rows ?? []).map((row) => ({ code: row.code, @@ -455,6 +505,44 @@ export async function soloLeaderRows(): Promise { })); } +/** + * Recompute cached_metrics from the live subtree for every non-suspect human node, + * repairing the drift that accumulates when the create→bump pair is interrupted + * (Neon HTTP has no cross-statement transaction) or when the countries counter + * races (MAJOR M2). Idempotent — safe to run on a schedule. Returns rows touched. + */ +export async function reconcileMetrics(): Promise { + const r = (await db.execute(sql` + WITH agg AS ( + SELECT + anc.id AS node_id, + COUNT(*) FILTER (WHERE d.id <> anc.id) AS reach, + COUNT(*) FILTER (WHERE d.referrer_id = anc.id) AS direct, + MAX(d.depth) AS max_depth, + COUNT(DISTINCT d.country) FILTER (WHERE d.country IS NOT NULL) AS countries + FROM nodes anc + JOIN nodes d ON d.path <@ anc.path AND d.class = 'human' AND d.suspect = false + WHERE anc.class = 'human' AND anc.suspect = false + GROUP BY anc.id + ) + INSERT INTO cached_metrics (node_id, reach, direct, max_depth, countries, updated_at) + SELECT node_id, reach, direct, max_depth, countries, now() FROM agg + ON CONFLICT (node_id) DO UPDATE SET + reach = EXCLUDED.reach, + direct = EXCLUDED.direct, + max_depth = EXCLUDED.max_depth, + countries = EXCLUDED.countries, + updated_at = now(); + `)) as unknown as { rowCount?: number }; + return r.rowCount ?? 0; +} + +/** Delete consumed/expired one-time tokens. Returns [magic, link] counts. */ +export async function reapExpiredTokens(): Promise { + await db.execute(sql`DELETE FROM magic_tokens WHERE expires_at < now();`); + await db.execute(sql`DELETE FROM link_tokens WHERE expires_at < now();`); +} + export type GlobeOverlay = { you: { lat: number; lng: number } | null; devices: { lat: number; lng: number }[]; diff --git a/src/db/index.ts b/src/db/index.ts index 11097e3..e9c1423 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -9,10 +9,24 @@ type DrizzleDb = let _db: DrizzleDb | null = null; +const STATEMENT_TIMEOUT_MS = 8_000; + function isLocal(url: string): boolean { return /@(localhost|127\.0\.0\.1|db)[:/]/.test(url) || !url.includes("neon.tech"); } +/** Add a statement_timeout startup option to a Neon connection string (idempotent). */ +function withStatementTimeout(url: string): string { + if (/statement_timeout/i.test(url)) return url; + try { + const u = new URL(url); + u.searchParams.set("options", `-c statement_timeout=${STATEMENT_TIMEOUT_MS}`); + return u.toString(); + } catch { + return url; + } +} + function getDb(): DrizzleDb { if (_db) return _db; const url = process.env.DATABASE_URL; @@ -22,11 +36,17 @@ function getDb(): DrizzleDb { // eslint-disable-next-line @typescript-eslint/no-require-imports const { Pool } = require("pg") as typeof import("pg"); const { drizzle } = require("drizzle-orm/node-postgres") as typeof import("drizzle-orm/node-postgres"); - _db = drizzle(new Pool({ connectionString: url }), { schema }); + // statement_timeout caps any single query so one slow scan can't pin a pooled + // connection under load (DESIGN §6.5 L2 — was a missing must-have v1 defense). + _db = drizzle(new Pool({ connectionString: url, statement_timeout: STATEMENT_TIMEOUT_MS }), { + schema, + }); } else { const { neon } = require("@neondatabase/serverless") as typeof import("@neondatabase/serverless"); const { drizzle } = require("drizzle-orm/neon-http") as typeof import("drizzle-orm/neon-http"); - _db = drizzle(neon(url), { schema }); + // The Neon HTTP driver is stateless (one round-trip per query), so SET can't + // persist. Pass statement_timeout as a startup option on the connection string. + _db = drizzle(neon(withStatementTimeout(url)), { schema }); } return _db; } diff --git a/src/db/migrations/0004_suspect_flag.sql b/src/db/migrations/0004_suspect_flag.sql new file mode 100644 index 0000000..acdc6f3 --- /dev/null +++ b/src/db/migrations/0004_suspect_flag.sql @@ -0,0 +1,5 @@ +ALTER TABLE "nodes" ADD COLUMN "suspect" boolean DEFAULT false NOT NULL;--> statement-breakpoint +-- Reach / leaderboard read paths count only non-suspect human nodes (DESIGN §6 +-- velocity dampening). Partial index keeps those scans off suspect rows. +CREATE INDEX "nodes_reach_eligible_idx" ON "nodes" USING gist ("path") + WHERE "class" = 'human' AND "suspect" = false; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index b988bd5..12d4796 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1782744918090, "tag": "0003_easy_inhumans", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1782831318090, + "tag": "0004_suspect_flag", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/reads.ts b/src/db/reads.ts index b85f9d9..bd0ac4b 100644 --- a/src/db/reads.ts +++ b/src/db/reads.ts @@ -21,7 +21,12 @@ export type LeaderRow = { /** Top-N by effective reach — one row per identity (solo node or linked account union). */ export async function leaderboard(limit = 20): Promise { - const [linked, solo] = await Promise.all([linkedAccountLeaderRows(), soloLeaderRows()]); + // Solo rows are bounded in SQL (top reach only); linked accounts are few. The final + // merge/sort/slice runs over O(limit) rows, not the whole node table (FATAL F2). + const [linked, solo] = await Promise.all([ + linkedAccountLeaderRows(), + soloLeaderRows(limit * 2), + ]); const rows = [...linked, ...solo] .sort((a, b) => b.reach - a.reach || Number(b.verified) - Number(a.verified)) .slice(0, limit); diff --git a/src/db/schema.ts b/src/db/schema.ts index 4a9756d..7685be6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -82,6 +82,7 @@ export const nodes = pgTable( class: text("class").notNull().default("human"), // human | bot | crawler | preview ephemeral: boolean("ephemeral").notNull().default(false), // incognito / unclaimed verified: boolean("verified").notNull().default(false), // belongs to verified account + suspect: boolean("suspect").notNull().default(false), // high risk score — excluded from reach/leaderboard (DESIGN §6) depth: integer("depth").notNull().default(0), // distance from root country: text("country"), // ISO-3166 alpha-2 lat: doublePrecision("lat"), diff --git a/src/db/seed-me.ts b/src/db/seed-me.ts index 3b8509e..ab6bd58 100644 --- a/src/db/seed-me.ts +++ b/src/db/seed-me.ts @@ -54,6 +54,7 @@ async function ensureNode(localId: string): Promise<{ id: number; code: string; referrerId: parentId, class: "human", ephemeral: false, + suspect: false, country: p.country, lat: p.lat, lng: p.lng, @@ -70,6 +71,7 @@ async function ensureNode(localId: string): Promise<{ id: number; code: string; referrerId: parentId, // ← your referrer (drives the blue arc) class: "human", ephemeral: false, + suspect: false, country: p.country, lat: p.lat, lng: p.lng, @@ -90,6 +92,7 @@ async function spawnChild(referrerId: number, tag: string) { referrerId, class: "human", ephemeral: false, + suspect: false, country: p.country, lat: p.lat, lng: p.lng, diff --git a/src/db/seed.ts b/src/db/seed.ts index 1ebe1ca..8835dbb 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -33,6 +33,7 @@ async function main() { referrerId, class: "human", ephemeral: false, + suspect: false, country, lat, lng, diff --git a/src/lib/client-identity.ts b/src/lib/client-identity.ts index 115a37c..5ffbc36 100644 --- a/src/lib/client-identity.ts +++ b/src/lib/client-identity.ts @@ -131,7 +131,10 @@ export type NodeResponse = { }; /** Register/resolve this device. Returns share code + metrics. */ -export async function registerNode(ref: string | null): Promise { +export async function registerNode( + ref: string | null, + isRetry = false, +): Promise { const localId = getLocalId(); // None of these signals may block registration — cap each with a timeout. const [fingerprint, incognito, botd] = await Promise.all([ @@ -168,13 +171,25 @@ export async function registerNode(ref: string | null): Promise null)) as { error?: string } | null; + if (err?.error === "identity_mismatch") { + document.cookie = `${LID_KEY}=;path=/;max-age=0;samesite=lax`; + return registerNode(ref, true); + } } if (!res.ok) { const body = await res.text().catch(() => ""); console.error(`[worldhello] register rejected: HTTP ${res.status} ${body}`); - return null; + throw new Error(`http_${res.status}`); } return (await res.json()) as NodeResponse; } diff --git a/src/lib/codes.ts b/src/lib/codes.ts index d6a4966..66f38f8 100644 --- a/src/lib/codes.ts +++ b/src/lib/codes.ts @@ -16,3 +16,8 @@ export function newLinkCode(): string { } export const MAX_DEPTH = 50; // DESIGN §6.5 — cap pathological linear chains. + +// risk_score (0–100) at/above this marks a node `suspect`: still created + rendered, +// but excluded from reach/leaderboard so identical-fingerprint mint farms can't +// inflate the scoreboard (DESIGN §6 velocity dampening). +export const SUSPECT_RISK_THRESHOLD = 70; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index ad9d0a8..f5fa80a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -95,6 +95,31 @@ export function useGlobe() { }); } +export type ReferrerCard = { + code: string; + country: string | null; + lat: number | null; + lng: number | null; + verified: boolean; + reach: number; + maxDepth: number; + countries: number; +}; + +/** Public referrer hook card for a ?ref landing (DESIGN §2 the hook). */ +export function useReferrerCard(code: string | null) { + return useQuery({ + queryKey: ["referrer", code], + queryFn: async () => { + const r = await fetch(`/api/referrer/${code}`); + if (!r.ok) return null; + return r.json() as Promise; + }, + enabled: !!code, + staleTime: 60_000, + }); +} + /** Leaderboard top-N. Polls. */ export function useLeaderboard() { return useQuery<{ rows: LeaderRow[] }>({ diff --git a/vercel.json b/vercel.json index 2672abd..4658f3e 100644 --- a/vercel.json +++ b/vercel.json @@ -3,5 +3,11 @@ "src/app/api/live/route.ts": { "maxDuration": 300 } - } + }, + "crons": [ + { + "path": "/api/cron/reconcile", + "schedule": "17 * * * *" + } + ] } From aa9094951961a8d8c7512c5e5ec347a0be050b9c Mon Sep 17 00:00:00 2001 From: dillonstreator Date: Thu, 2 Jul 2026 09:53:53 -0500 Subject: [PATCH 2/4] Sweep minors: dead code, error-page leak, copy overpromises - Remove dead exports never called anywhere: reads.meDetail (superseded by meBundle), reads.rankOf, graph.ancestry, format.fmtFull, magic-verify.peekMagicToken. - error.tsx: stop rendering raw error.message to users (info leak); show a generic message + digest ref, and align to design tokens instead of ad-hoc zinc/sky colors. - About copy honesty: drop the "precise placement is strictly opt-in" claim (the opt-in precise-geo path was never built) and the analytics reference is already gone; add a plain-language note on why the fingerprint exists and that it's never used for cross-site tracking. Co-Authored-By: Claude Fable 5 --- src/app/about/page.tsx | 10 +++++--- src/app/error.tsx | 15 ++++++----- src/db/graph.ts | 16 ------------ src/db/reads.ts | 56 ----------------------------------------- src/lib/format.ts | 7 ------ src/lib/magic-verify.ts | 10 -------- 6 files changed, 13 insertions(+), 101 deletions(-) diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index e1e25d9..6f918aa 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -67,7 +67,9 @@ export default function About() {
You're recognized by a random per-device ID plus a privacy-preserving fingerprint — enough - to keep your web yours across visits, never enough to know who you are. + to keep your web yours across visits, never enough to know who you are. The + fingerprint is used only to reconnect you to your own chain and to keep bots out — never to + track you across other sites. You can erase it below at any time. Fingerprints and IP addresses are never stored in the clear. They're passed through a @@ -75,9 +77,9 @@ export default function About() { device or network. - The globe places you at an approximate, jittered city-level point derived from your connection — - never your precise location, and you are never prompted for it. Precise placement is strictly - opt-in. + The globe places you at an approximate, jittered city-level point derived from your + connection — never your precise location, and you are never prompted for it. Your browser's + location permission is never requested. No third-party ad pixels, no cross-site trackers, no behavioral profiling, and no analytics diff --git a/src/app/error.tsx b/src/app/error.tsx index 1250dda..84cf8a7 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -9,15 +9,14 @@ export default function Error({ }) { return (
-

Something went wrong

-

- {error.message || "An unexpected error occurred. Please try again."} +

Something went wrong

+

+ The globe hit a snag loading. Try again — if it keeps happening, reload the page.

-
diff --git a/src/db/graph.ts b/src/db/graph.ts index a038dc9..ba975ed 100644 --- a/src/db/graph.ts +++ b/src/db/graph.ts @@ -675,19 +675,3 @@ export async function globeOverlayForNode(nodeId: number): Promise { - const r = (await db.execute(sql` - WITH me AS (SELECT path FROM nodes WHERE id = ${nodeId}), - ids AS ( - SELECT (regexp_split_to_table((SELECT path::text FROM me), '\\.'))::bigint AS id - ) - SELECT n.id, n.lat, n.lng - FROM nodes n JOIN ids ON ids.id = n.id - ORDER BY n.depth ASC; - `)) as unknown as { rows: { id: number; lat: number | null; lng: number | null }[] }; - return r.rows ?? []; -} diff --git a/src/db/reads.ts b/src/db/reads.ts index bd0ac4b..77abf8e 100644 --- a/src/db/reads.ts +++ b/src/db/reads.ts @@ -163,52 +163,6 @@ export async function globeData(): Promise { return { mode: "binned", points, arcs, total }; } -/** - * Per-device detail for the dashboard + globe overlay: your position, the arc - * that reached you (incoming, blue), arcs to people you brought (outgoing, purple). - */ -export async function meDetail(code: string): Promise<{ - you: { lat: number; lng: number } | null; - incoming: GlobeArc[]; - outgoing: GlobeArc[]; - referrer: { lat: number | null; lng: number | null } | null; -} | null> { - const meR = (await db.execute(sql` - SELECT id, referrer_id AS "referrerId", lat, lng FROM nodes WHERE code = ${code} LIMIT 1; - `)) as unknown as { rows: { id: number; referrerId: number | null; lat: number | null; lng: number | null }[] }; - const me = meR.rows?.[0]; - if (!me) return null; - - const you = me.lat != null && me.lng != null ? { lat: me.lat, lng: me.lng } : null; - - // Incoming: parent → you (blue). - let incoming: GlobeArc[] = []; - let referrer: { lat: number | null; lng: number | null } | null = null; - if (me.referrerId != null) { - const p = (await db.execute(sql` - SELECT lat, lng FROM nodes WHERE id = ${me.referrerId} LIMIT 1; - `)) as unknown as { rows: { lat: number | null; lng: number | null }[] }; - const par = p.rows?.[0]; - if (par && par.lat != null && par.lng != null && you) { - referrer = par; - incoming = [{ sx: par.lng, sy: par.lat, ex: you.lng, ey: you.lat }]; - } - } - - // Outgoing: you → direct children (purple), capped. - const ch = (await db.execute(sql` - SELECT lat, lng FROM nodes - WHERE referrer_id = ${me.id} AND class = 'human' AND lat IS NOT NULL - ORDER BY created_at DESC LIMIT 40; - `)) as unknown as { rows: { lat: number; lng: number }[] }; - const outgoing: GlobeArc[] = - you == null - ? [] - : (ch.rows ?? []).map((c) => ({ sx: you.lng, sy: you.lat, ex: Number(c.lng), ey: Number(c.lat) })); - - return { you, incoming, outgoing, referrer }; -} - /** * Bundle for /api/me: globe overlay + metrics + rank. * Unlinked devices read denormalized cached_metrics; multi-device accounts use live union. @@ -246,13 +200,3 @@ export async function meBundle(code: string): Promise<{ rank, }; } - -/** Global rank + percentile for a node (anti-hopeless, DESIGN §2 leaderboard). */ -export async function rankOf(code: string): Promise<{ rank: number; percentile: number } | null> { - const r = (await db.execute(sql` - SELECT id FROM nodes WHERE code = ${code} LIMIT 1; - `)) as unknown as { rows: { id: number }[] }; - const node = r.rows?.[0]; - if (!node) return null; - return rankForNodeId(node.id); -} diff --git a/src/lib/format.ts b/src/lib/format.ts index 52002eb..1f9589c 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -13,13 +13,6 @@ export function fmtCompact(n: number | null | undefined): string { return compact.format(n); } -/** Full grouped number ("1,284") for places with room (e.g. body copy). */ -const full = new Intl.NumberFormat("en"); -export function fmtFull(n: number | null | undefined): string { - if (n == null || !Number.isFinite(n)) return "—"; - return full.format(n); -} - /** Rank like "#142", compacted for huge ranks ("#1.2M"). */ export function fmtRank(rank: number | null | undefined): string { if (rank == null || !Number.isFinite(rank)) return "#—"; diff --git a/src/lib/magic-verify.ts b/src/lib/magic-verify.ts index 1c52be0..b1df822 100644 --- a/src/lib/magic-verify.ts +++ b/src/lib/magic-verify.ts @@ -110,13 +110,3 @@ export async function executeMagicVerify( return "ok"; } - -/** Non-consuming check that a signed token still exists and is unexpired. */ -export async function peekMagicToken(nonce: string): Promise { - const r = (await db.execute(sql` - SELECT 1 FROM magic_tokens - WHERE nonce = ${nonce} AND expires_at > now() - LIMIT 1; - `)) as unknown as { rows: unknown[] }; - return !!r.rows?.[0]; -} From f814da6c6dabb94b9b58ad87956a9f319b0c1af8 Mon Sep 17 00:00:00 2001 From: dillonstreator Date: Thu, 2 Jul 2026 10:06:03 -0500 Subject: [PATCH 3/4] Make cron deploy-safe on Hobby: daily schedule + vercel.json schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconcile cron was scheduled hourly (`17 * * * *`), which fails Vercel deployment on Hobby accounts ("limited to daily cron jobs. This cron expression would run more than once per day"). Reconciliation and token reaping don't need hourly cadence, so switch to once-daily (`17 4 * * *`, ~04:17 UTC) — valid on Hobby, Pro, and Enterprise. The route's Bearer-token guard already matches Vercel's documented pattern: Vercel sends `Authorization: Bearer $CRON_SECRET` only when CRON_SECRET is set in project env, and the handler 503s without a secret / 401s on mismatch. Set CRON_SECRET in the Vercel project for the job to run (documented in .env.example). Also add the $schema reference to vercel.json for config validation. Co-Authored-By: Claude Fable 5 --- vercel.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index 4658f3e..0e92316 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,5 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "functions": { "src/app/api/live/route.ts": { "maxDuration": 300 @@ -7,7 +8,7 @@ "crons": [ { "path": "/api/cron/reconcile", - "schedule": "17 * * * *" + "schedule": "17 4 * * *" } ] } From a0a5108107aab316668160fd720dacef09c59dfe Mon Sep 17 00:00:00 2001 From: dillonstreator Date: Thu, 2 Jul 2026 10:50:47 -0500 Subject: [PATCH 4/4] Add token-authed post-deploy migration endpoint + CI trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neon deploys don't run migrations automatically, and drizzle-kit isn't available in the serverless runtime, so schema changes (like the new suspect column) would never reach production. Add an idempotent, secret-guarded runner that CI invokes after each production deploy. - /api/migrate (POST, Bearer MIGRATE_SECRET): applies pending Drizzle migrations against the UNPOOLED endpoint (DDL + advisory lock need a direct connection). 503 without the secret, 401 on mismatch, 200 on apply/no-op. Chooses the neon-http or node-postgres migrator by URL, mirroring src/db/migrate.ts. - next.config.ts outputFileTracingIncludes bundles src/db/migrations/** into that function — the migrator reads the raw SQL + journal at runtime and the tracer won't include non-imported files otherwise. Verified 10 migration files land in the function's .nft trace. - .github/workflows/deploy-migrate.yml listens for a successful Production deployment_status (Vercel's Git integration reports these to GitHub) and POSTs the endpoint at the deployment URL with the MIGRATE_SECRET GitHub secret. Verified end-to-end against a wiped local DB: all tables, the suspect column, the ltree extension, and the set_node_path trigger apply; re-run is a clean no-op; auth rejects missing/wrong tokens. Set MIGRATE_SECRET in both Vercel project env and GitHub Actions secrets. Co-Authored-By: Claude Fable 5 --- .env.example | 6 +++ .github/workflows/deploy-migrate.yml | 41 +++++++++++++++++++ next.config.ts | 5 +++ src/app/api/migrate/route.ts | 61 ++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 .github/workflows/deploy-migrate.yml create mode 100644 src/app/api/migrate/route.ts diff --git a/.env.example b/.env.example index 76ec472..37c5ae7 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ KV_REST_API_TOKEN="" # endpoint refuses all requests (reconciliation + token reaping won't run). CRON_SECRET="" +# Secret guarding the post-deploy migration endpoint (/api/migrate). The CI +# workflow .github/workflows/deploy-migrate.yml POSTs it as a Bearer token after a +# production deploy. Set the SAME value as a GitHub Actions secret named +# MIGRATE_SECRET. If unset the endpoint refuses all requests. +MIGRATE_SECRET="" + # ── Email (magic-link) ── falls back in this order: Resend → SMTP → dev console. # 1. Resend (preferred in prod) RESEND_API_KEY="" diff --git a/.github/workflows/deploy-migrate.yml b/.github/workflows/deploy-migrate.yml new file mode 100644 index 0000000..e13688c --- /dev/null +++ b/.github/workflows/deploy-migrate.yml @@ -0,0 +1,41 @@ +name: Post-deploy migrate + +# Vercel deploys via its Git integration and reports back to GitHub as a +# deployment_status. When a PRODUCTION deployment succeeds, hit the token-authed +# /api/migrate endpoint on that exact deployment URL to apply pending migrations. +# The migrator is idempotent, so re-runs are safe no-ops. + +on: + deployment_status: + +jobs: + migrate: + # Only production deployments that finished successfully. + if: >- + github.event.deployment_status.state == 'success' && + github.event.deployment.environment == 'Production' + runs-on: ubuntu-latest + steps: + - name: Apply migrations on the deployed instance + env: + MIGRATE_SECRET: ${{ secrets.MIGRATE_SECRET }} + TARGET_URL: ${{ github.event.deployment_status.environment_url || github.event.deployment_status.target_url }} + run: | + set -euo pipefail + if [ -z "${MIGRATE_SECRET:-}" ]; then + echo "MIGRATE_SECRET secret is not set — skipping migrate." >&2 + exit 1 + fi + if [ -z "${TARGET_URL:-}" ]; then + echo "No deployment URL on the event — cannot migrate." >&2 + exit 1 + fi + echo "Migrating against ${TARGET_URL%/}/api/migrate" + code=$(curl -sS -o /tmp/body.txt -w '%{http_code}' \ + -X POST "${TARGET_URL%/}/api/migrate" \ + -H "Authorization: Bearer ${MIGRATE_SECRET}") + echo "HTTP ${code}" + cat /tmp/body.txt + echo + # 200 = applied/no-op. Anything else fails the job so a bad migration is loud. + [ "$code" = "200" ] diff --git a/next.config.ts b/next.config.ts index 594e1af..7e7277a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -24,6 +24,11 @@ const securityHeaders = [ const nextConfig: NextConfig = { turbopack: { root: __dirname }, allowedDevOrigins: ["192.168.1.244", "192.168.1.244:3000"], + // The /api/migrate function reads the raw Drizzle SQL + journal at runtime, so the + // tracer must bundle the migrations folder into that function (it isn't imported). + outputFileTracingIncludes: { + "/api/migrate": ["./src/db/migrations/**/*"], + }, async headers() { return [{ source: "/(.*)", headers: securityHeaders }]; }, diff --git a/src/app/api/migrate/route.ts b/src/app/api/migrate/route.ts new file mode 100644 index 0000000..96d31df --- /dev/null +++ b/src/app/api/migrate/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import { logApiError, logApiReject } from "@/lib/api-log"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +/** + * Post-deploy migration runner. Applies any pending Drizzle migrations against the + * UNPOOLED Neon endpoint (DDL + the advisory lock the migrator takes need a direct, + * non-PgBouncer connection). Triggered by CI after a production deploy — see + * .github/workflows/deploy-migrate.yml. + * + * Auth: Bearer MIGRATE_SECRET. Migrator is idempotent (journal-tracked), so a + * duplicate call is a no-op. The migrations SQL is bundled into this function via + * outputFileTracingIncludes in next.config.ts. + */ +export async function POST(req: NextRequest) { + const secret = process.env.MIGRATE_SECRET; + if (!secret) { + logApiReject("migrate", "no_secret"); + return NextResponse.json({ error: "not_configured" }, { status: 503 }); + } + if (req.headers.get("authorization") !== `Bearer ${secret}`) { + logApiReject("migrate", "unauthorized"); + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const url = process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL; + if (!url) { + logApiError("migrate", "no database url"); + return NextResponse.json({ error: "no_database_url" }, { status: 503 }); + } + + const migrationsFolder = path.join(process.cwd(), "src/db/migrations"); + + try { + if (url.includes("neon.tech")) { + const { neon } = await import("@neondatabase/serverless"); + const { drizzle } = await import("drizzle-orm/neon-http"); + const { migrate } = await import("drizzle-orm/neon-http/migrator"); + await migrate(drizzle(neon(url)), { migrationsFolder }); + } else { + // Non-Neon (e.g. a self-hosted Postgres deploy) — use node-postgres. + const { Pool } = await import("pg"); + const { drizzle } = await import("drizzle-orm/node-postgres"); + const { migrate } = await import("drizzle-orm/node-postgres/migrator"); + const pool = new Pool({ connectionString: url }); + try { + await migrate(drizzle(pool), { migrationsFolder }); + } finally { + await pool.end(); + } + } + return NextResponse.json({ ok: true }); + } catch (err) { + logApiError("migrate", "migration failed", err); + return NextResponse.json({ error: "migration_failed" }, { status: 500 }); + } +}