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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/deploy-migrate.yml
Original file line number Diff line number Diff line change
@@ -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" ]
5 changes: 5 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
},
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 0 additions & 37 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 65 additions & 0 deletions src/app/[code]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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/<code>
* 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) => (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ display: "flex", fontSize: 72, fontWeight: 800, color: "#a78bfa" }}>{value}</div>
<div style={{ display: "flex", fontSize: 26, color: "#9090b0", marginTop: 6 }}>{label}</div>
</div>
);

return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "radial-gradient(circle at 50% 35%, #17172a 0%, #0a0a0f 60%)",
color: "#e8e8f0",
fontFamily: "sans-serif",
}}
>
<div style={{ display: "flex", fontSize: 32, letterSpacing: 3, color: "#a0a0c0" }}>
SOMEONE STARTED A CHAIN
</div>
{card ? (
<div style={{ display: "flex", gap: 80, marginTop: 44 }}>
{stat(fmtCompact(card.reach), "people reached")}
{stat(fmtCompact(card.countries), "countries")}
{stat(fmtCompact(card.maxDepth), "degrees deep")}
</div>
) : (
<div style={{ display: "flex", marginTop: 40, fontSize: 60, fontWeight: 800 }}>
Join the chain
</div>
)}
<div style={{ display: "flex", marginTop: 48, fontSize: 40, fontWeight: 700 }}>
Continue the chain →
</div>
<div style={{ display: "flex", marginTop: 16, fontSize: 26, color: "#9090b0" }}>
Anonymous · no sign-up
</div>
</div>
),
size,
);
}
32 changes: 26 additions & 6 deletions src/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -66,21 +67,23 @@ export default function About() {
</Technique>
<Technique title="Device identity, not personal identity">
You&apos;re recognized by a random per-device ID plus a privacy-preserving fingerprint — enough
to keep <em>your</em> web yours across visits, never enough to know who you are.
to keep <em>your</em> 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.
</Technique>
<Technique title="No raw identifiers stored">
Fingerprints and IP addresses are never stored in the clear. They&apos;re passed through a
keyed one-way hash, so the database holds opaque tokens that can&apos;t be reversed back to a
device or network.
</Technique>
<Technique title="Coarse location, never asked">
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&apos;s
location permission is never requested.
</Technique>
<Technique title="No tracking pixels">
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.
</Technique>
<Technique title="Minimal, auditable footprint">
Each node stores only what the visualization needs: a share code, a referrer link, a coarse
Expand All @@ -98,6 +101,23 @@ export default function About() {
</div>
</Section>

<Section title="What we keep, and for how long">
<p>
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&apos; chains hang off it.
</p>
<p>
You can erase your device&apos;s personal data at any time — no email, no account, no support
ticket. It&apos;s keyed to your browser, so do it from the device you want scrubbed.
</p>
<div className="mt-4">
<DeleteDataButton />
</div>
</Section>

<Section title="Open source">
<p>
worldhello is built in the open. The privacy claims above are verifiable because the code that
Expand Down
36 changes: 36 additions & 0 deletions src/app/api/auth/delete/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 14 additions & 7 deletions src/app/api/auth/magic/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: `<p>Click below to verify your account on ${host}:</p><p><a href="${link}">Verify my account</a></p><p>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.</p>`,
}).catch((err) => {
console.error("[auth/magic] send failed:", err);
});
try {
await sendMail({
to: email,
subject: `Verify your ${host} account`,
html: `<p>Click below to verify your account on ${host}:</p><p><a href="${link}">Verify my account</a></p><p>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.</p>`,
});
} 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 {
Expand Down
Loading
Loading