diff --git a/.env.example b/.env.example index 13ecb4c..37c5ae7 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,17 @@ 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="" + +# 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/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/.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/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..6f918aa 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(); @@ -66,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 @@ -74,13 +77,13 @@ 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. - 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 +101,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/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 }); + } +} 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/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/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..ba975ed 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 }[]; @@ -587,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/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..77abf8e 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); @@ -158,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. @@ -241,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/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/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]; -} 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..0e92316 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,14 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "functions": { "src/app/api/live/route.ts": { "maxDuration": 300 } - } + }, + "crons": [ + { + "path": "/api/cron/reconcile", + "schedule": "17 4 * * *" + } + ] }