From b46c7c6922c9d55aa8cc1c9177326a88be5154ba Mon Sep 17 00:00:00 2001 From: arko33 <221298156+arko33@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:43:12 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Ghost=20MVP=20=E2=80=94=20recorder?= =?UTF-8?q?=20CLI,=20OpenCode=20adapter,=20Ghost=20Cloud=20auth=20&=20sess?= =?UTF-8?q?ions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/ghost: local recorder (shadowgit checkpoints, runtime probes, scrubbed sync) - packages/opencode-plugin: Mode A adapter streaming turn events to recorder - Ghost Cloud: GitHub OAuth login, device pairing (/pair), sessions ingest API (v1) - db/schema.sql: Neon schema (users/devices/pairing/projects/sessions/turns/checkpoints/samples) - pages: /login, /pair; dep: pg --- .gitignore | 3 + app/api/auth/github/callback/route.ts | 55 + app/api/auth/github/route.ts | 29 + app/api/auth/logout/route.ts | 12 + app/api/v1/pair/confirm/route.ts | 43 + app/api/v1/pair/route.ts | 41 + app/api/v1/sessions/route.ts | 148 +++ app/globals.css | 65 +- app/layout.tsx | 18 +- app/login/login.css | 27 + app/login/page.tsx | 21 + app/pair/page.tsx | 15 + app/pair/pair-client.tsx | 65 ++ app/pair/pair.css | 50 + app/styles/landing.css | 114 +- db/schema.sql | 99 ++ package-lock.json | 172 ++- package.json | 5 +- packages/ghost/ghost.mjs | 239 ++++ packages/ghost/lib/probes.mjs | 53 + packages/ghost/lib/shadowgit.mjs | 61 ++ packages/ghost/lib/store.mjs | 120 +++ packages/opencode-plugin/index.ts | 62 ++ scripts/apply-schema.mjs | 19 + src/components/site/GhostCanvas.tsx | 1318 +++++++++++++++++------ src/components/site/cityChoreography.ts | 375 ++++--- src/components/site/cityData.ts | 235 ++-- src/lib/auth.ts | 116 ++ src/lib/db.ts | 36 + src/lib/origin.ts | 8 + tsconfig.json | 2 +- 31 files changed, 3016 insertions(+), 610 deletions(-) create mode 100644 app/api/auth/github/callback/route.ts create mode 100644 app/api/auth/github/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/v1/pair/confirm/route.ts create mode 100644 app/api/v1/pair/route.ts create mode 100644 app/api/v1/sessions/route.ts create mode 100644 app/login/login.css create mode 100644 app/login/page.tsx create mode 100644 app/pair/page.tsx create mode 100644 app/pair/pair-client.tsx create mode 100644 app/pair/pair.css create mode 100644 db/schema.sql create mode 100644 packages/ghost/ghost.mjs create mode 100644 packages/ghost/lib/probes.mjs create mode 100644 packages/ghost/lib/shadowgit.mjs create mode 100644 packages/ghost/lib/store.mjs create mode 100644 packages/opencode-plugin/index.ts create mode 100644 scripts/apply-schema.mjs create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/origin.ts diff --git a/.gitignore b/.gitignore index 631f929..4fed501 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ testsprite_tests/ .vercel/ testsprite_tests/tmp/ testsprite_tests/tmp/config.json + +.vercel +.env* diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts new file mode 100644 index 0000000..0e0c6ea --- /dev/null +++ b/app/api/auth/github/callback/route.ts @@ -0,0 +1,55 @@ +// GET /api/auth/github/callback — exchange code, upsert user, set session. +import { NextRequest, NextResponse } from "next/server"; +import { newSessionCookie, readOAuthState } from "@/src/lib/auth"; +import { q, type UserRow } from "@/src/lib/db"; +import { originFrom } from "@/src/lib/origin"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const saved = readOAuthState(req); + + if (!code) return nextResponse(req, { error: "missing code" }); + if (!saved || saved.state !== state) return nextResponse(req, { error: "oauth state mismatch" }); + + // token exchange + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: `${originFrom(req)}/api/auth/github/callback`, + }), + }); + const tokenJson = (await tokenRes.json()) as { access_token?: string; error?: string }; + if (!tokenJson.access_token) return nextResponse(req, { error: tokenJson.error ?? "token exchange failed" }); + + const ghRes = await fetch("https://api.github.com/user", { + headers: { authorization: `Bearer ${tokenJson.access_token}`, accept: "application/vnd.github+json", "user-agent": "ghostinthecodebase" }, + }); + if (!ghRes.ok) return nextResponse(req, { error: `github profile fetch failed (${ghRes.status})` }); + const gh = (await ghRes.json()) as { id: number; login: string; name?: string; email?: string; avatar_url?: string }; + + const { rows } = await q( + `INSERT INTO users (github_id, login, name, email, avatar_url) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (github_id) DO UPDATE + SET login = EXCLUDED.login, name = EXCLUDED.name, email = EXCLUDED.email, avatar_url = EXCLUDED.avatar_url + RETURNING id, github_id, login, name, avatar_url`, + [String(gh.id), gh.login, gh.name ?? null, gh.email ?? null, gh.avatar_url ?? null], + ); + const user = rows[0]; + + const res = NextResponse.redirect(`${originFrom(req)}${saved.next}`); + newSessionCookie(res, { id: user.id, login: user.login }); + return res; +} + +function nextResponse(req: NextRequest, { error }: { error: string }) { + return NextResponse.redirect(`${originFrom(req)}/login?error=${encodeURIComponent(error)}`); +} diff --git a/app/api/auth/github/route.ts b/app/api/auth/github/route.ts new file mode 100644 index 0000000..8782d22 --- /dev/null +++ b/app/api/auth/github/route.ts @@ -0,0 +1,29 @@ +// GET /api/auth/github — kick off GitHub OAuth web sign-in. +// Optional ?next=/pair?code=… so the browser lands back on the pairing page +// after login (device-code binding stays out of OAuth — CLI never logs in). +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { setOAuthState } from "@/src/lib/auth"; +import { originFrom } from "@/src/lib/origin"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const clientId = process.env.GITHUB_CLIENT_ID; + if (!clientId) return new NextResponse("GitHub OAuth not configured (GITHUB_CLIENT_ID)", { status: 500 }); + + const url = new URL(req.url); + const next = (url.searchParams.get("next") ?? "/").toString(); + const state = crypto.randomBytes(12).toString("hex"); + + const authUrl = new URL("https://github.com/login/oauth/authorize"); + authUrl.searchParams.set("client_id", clientId); + authUrl.searchParams.set("redirect_uri", `${originFrom(req)}/api/auth/github/callback`); + authUrl.searchParams.set("scope", "read:user"); + authUrl.searchParams.set("state", state); + + const res = NextResponse.redirect(authUrl.toString()); + // `next` validated downstream (must start with "/" — never an absolute URL) + setOAuthState(res, state, next.startsWith("/") ? next : "/"); + return res; +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..3304cb0 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +// GET /api/auth/logout — clear the web session and go home. +import { NextRequest, NextResponse } from "next/server"; +import { clearSessionCookie } from "@/src/lib/auth"; +import { originFrom } from "@/src/lib/origin"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const res = NextResponse.redirect(`${originFrom(req)}/`); + clearSessionCookie(res); + return res; +} diff --git a/app/api/v1/pair/confirm/route.ts b/app/api/v1/pair/confirm/route.ts new file mode 100644 index 0000000..e215fb1 --- /dev/null +++ b/app/api/v1/pair/confirm/route.ts @@ -0,0 +1,43 @@ +// POST /api/v1/pair/confirm — the human confirms a pairing code in the browser. +// Requires the web session cookie (GitHub OAuth). Issues a device token: +// raw token lives only in the pending pairing row for the CLI to pick up; +// devices stores just its sha256. +import { NextRequest, NextResponse } from "next/server"; +import { userFromRequest, newDeviceToken, deviceTokenHash } from "@/src/lib/auth"; +import { q } from "@/src/lib/db"; + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + const user = userFromRequest(req); + if (!user) return NextResponse.json({ error: "sign in first" }, { status: 401 }); + + const body = await req.json().catch(() => ({})); + const code = typeof body.code === "string" ? body.code : ""; + if (!code) return NextResponse.json({ error: "missing code" }, { status: 400 }); + + const { rows } = await q( + `SELECT user_id, raw_token, bound_at, expires_at FROM pairing_codes WHERE code = $1 LIMIT 1`, + [code], + ); + if (!rows.length) return NextResponse.json({ error: "code not found" }, { status: 404 }); + const row = rows[0]; + if (row.user_id || row.raw_token) { + return NextResponse.json({ error: "code already bound" }, { status: 409 }); + } + if (new Date(row.expires_at).getTime() < Date.now()) { + return NextResponse.json({ error: "code expired" }, { status: 410 }); + } + + const token = newDeviceToken(); + await q(`INSERT INTO devices (user_id, name, token_hash) VALUES ($1, $2, $3)`, [ + user.uid, + `cli@${user.login}`, + deviceTokenHash(token), + ]); + await q( + `UPDATE pairing_codes SET user_id = $1, raw_token = $2, bound_at = now() WHERE code = $3`, + [user.uid, token, code], + ); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/v1/pair/route.ts b/app/api/v1/pair/route.ts new file mode 100644 index 0000000..ce26efb --- /dev/null +++ b/app/api/v1/pair/route.ts @@ -0,0 +1,41 @@ +// Device pairing — the CLI's only auth channel. No OAuth for agents, ever. +// POST /api/v1/pair → { code, expiresAt } (CLI prints the URL) +// GET /api/v1/pair?code=… → { status: "pending" | "bound" | "expired" | "not_found" } +// (once bound: { status:"bound", deviceToken }) +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { q } from "@/src/lib/db"; + +export const runtime = "nodejs"; + +const WINDOW_MINUTES = 15; + +const newCode = () => "ghost-" + crypto.randomBytes(4).toString("hex").toUpperCase(); + +export async function POST() { + const code = newCode(); + const { rows } = await q( + `INSERT INTO pairing_codes (code, expires_at) VALUES ($1, now() + interval '${WINDOW_MINUTES} minutes') + RETURNING code, expires_at`, + [code], + ); + return NextResponse.json({ code: rows[0].code, expiresAt: rows[0].expires_at, windowMinutes: WINDOW_MINUTES }); +} + +export async function GET(req: NextRequest) { + const code = new URL(req.url).searchParams.get("code"); + if (!code) return NextResponse.json({ error: "missing code" }, { status: 400 }); + + const { rows } = await q( + `SELECT user_id, raw_token, bound_at, expires_at FROM pairing_codes WHERE code = $1 LIMIT 1`, + [code], + ); + if (!rows.length) return NextResponse.json({ status: "not_found" }, { status: 404 }); + if (rows[0].bound_at) { + return NextResponse.json({ status: "bound", deviceToken: rows[0].raw_token }); + } + if (new Date(rows[0].expires_at).getTime() < Date.now()) { + return NextResponse.json({ status: "expired" }, { status: 410 }); + } + return NextResponse.json({ status: "pending", expiresAt: rows[0].expires_at }); +} diff --git a/app/api/v1/sessions/route.ts b/app/api/v1/sessions/route.ts new file mode 100644 index 0000000..709ed6d --- /dev/null +++ b/app/api/v1/sessions/route.ts @@ -0,0 +1,148 @@ +// Session API — sync target for the local Ghost recorder. +// POST /api/v1/sessions auth: Bearer body: session contract v1 +// GET /api/v1/sessions auth: web session cookie -> latest 50 sessions +// +// Policy (mirrors the recorder): the client scrubs secrets before sync, and +// prompts/diffs leave the machine only when sync=full was consented to. +import { NextRequest, NextResponse } from "next/server"; +import { deviceFromRequest, userFromRequest } from "@/src/lib/auth"; +import { q } from "@/src/lib/db"; + +export const runtime = "nodejs"; + +type IncomingSession = { + v?: number; + id?: string; + repo?: string; + status?: string; + head?: string | null; + startedAt?: string; + endedAt?: string | null; + sync?: string; + turns?: Array<{ index?: number; id?: string | null; estimated?: boolean; agent?: string | null; prompt?: string | null; startedAt?: string; endedAt?: string | null; files?: string[] }>; + checkpoints?: Array<{ turnNumber?: number | null; sha?: string; files?: string[]; ts?: string }>; + samples?: Array<{ ts?: string; turnNumber?: number | null; probeFailed?: boolean; metrics?: Record }>; +}; + +export async function POST(req: NextRequest) { + const device = await deviceFromRequest(req); + if (!device) return NextResponse.json({ error: "unauthorized device" }, { status: 401 }); + + const body = (await req.json().catch(() => null)) as IncomingSession | null; + if (!body || body.v !== 1 || !body.id || !body.repo) { + return NextResponse.json({ error: "expected session contract v1 {id, repo, …}" }, { status: 400 }); + } + + // project is per-user and keyed by the local repo name + const { rows: projRows } = await q<{ id: string }>( + `INSERT INTO projects (user_id, name) VALUES ($1, $2) + ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, + [device.userId, body.repo], + ); + const projectId = projRows[0].id; + + const { rows: sessRows } = await q<{ id: string }>( + `INSERT INTO sessions (project_id, external_ref, status, head, started_at, ended_at, sync, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + projectId, + body.id, + body.status ?? "recorded", + body.head ?? null, + body.startedAt ? safeDate(body.startedAt) : null, + body.endedAt ? safeDate(body.endedAt) : null, + body.sync ?? "off", + body as unknown, + ], + ); + const sessionId = sessRows[0].id; + + for (const t of body.turns ?? []) { + await q( + `INSERT INTO turns (session_id, turn_index, external_id, estimated, agent, prompt, started_at, ended_at, files) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + sessionId, + t.index ?? null, + t.id ?? null, + !!t.estimated, + t.agent ?? null, + t.prompt ?? null, + t.startedAt ? safeDate(t.startedAt) : null, + t.endedAt ? safeDate(t.endedAt) : null, + JSON.stringify(t.files ?? []), + ], + ); + } + for (const c of body.checkpoints ?? []) { + await q( + `INSERT INTO checkpoints (session_id, turn_index, sha, files) VALUES ($1, $2, $3, $4)`, + [sessionId, c.turnNumber ?? null, c.sha ?? null, JSON.stringify(c.files ?? [])], + ); + } + for (const s of body.samples ?? []) { + await q( + `INSERT INTO metric_samples (session_id, turn_index, captured_at, probe_failed, metrics) + VALUES ($1, $2, $3, $4, $5)`, + [sessionId, s.turnNumber ?? null, s.ts ? safeDate(s.ts) : null, !!s.probeFailed, JSON.stringify(s.metrics ?? {})], + ); + } + + return NextResponse.json({ sessionId, externalRef: body.id, synced: true }); +} + +type ListRow = { + session_id: string; + external_ref: string; + status: string; + sync: string; + started_at: string; + ended_at: string | null; + project_name: string; + head: string | null; + turn_count: string; + checkpoint_count: string; + sample_count: string; +}; + +export async function GET(req: NextRequest) { + const user = userFromRequest(req); + if (!user) return NextResponse.json({ error: "sign in first" }, { status: 401 }); + + const { rows } = await q( + `SELECT s.id AS session_id, s.external_ref, s.status, s.sync, s.started_at, s.ended_at, + s.head, p.name AS project_name, + (SELECT count(*) FROM turns t WHERE t.session_id = s.id) AS turn_count, + (SELECT count(*) FROM checkpoints c WHERE c.session_id = s.id) AS checkpoint_count, + (SELECT count(*) FROM metric_samples m WHERE m.session_id = s.id) AS sample_count + FROM sessions s + JOIN projects p ON p.id = s.project_id + WHERE p.user_id = $1 + ORDER BY s.created_at DESC + LIMIT 50`, + [user.uid], + ); + + return NextResponse.json({ + sessions: rows.map((r) => ({ + id: r.session_id, + externalRef: r.external_ref, + project: r.project_name, + status: r.status, + sync: r.sync, + head: r.head, + startedAt: r.started_at, + endedAt: r.ended_at, + turns: Number(r.turn_count), + checkpoints: Number(r.checkpoint_count), + samples: Number(r.sample_count), + })), + }); +} + +function safeDate(v: string): string | null { + const d = new Date(v); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} diff --git a/app/globals.css b/app/globals.css index 6719222..927bf45 100644 --- a/app/globals.css +++ b/app/globals.css @@ -42,7 +42,8 @@ rgba(103, 232, 249, 0.45) ); - --font-display-stack: var(--font-display), "Times New Roman", serif; + --font-display-stack: var(--font-sans), ui-sans-serif, system-ui, + -apple-system, "Segoe UI", sans-serif; --font-sans-stack: var(--font-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono-stack: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, @@ -494,21 +495,21 @@ a { background: radial-gradient( 98% 46% at 50% 56%, - rgba(139, 124, 255, 0.22) 0%, - rgba(103, 232, 249, 0.05) 42%, + rgba(64, 158, 128, 0.2) 0%, + rgba(28, 82, 68, 0.06) 42%, transparent 72% ), radial-gradient( 120% 80% at 50% 0%, - rgba(139, 124, 255, 0.12) 0%, + rgba(58, 140, 112, 0.1) 0%, transparent 55% ), radial-gradient( 100% 70% at 80% 70%, - rgba(103, 232, 249, 0.08) 0%, + rgba(110, 210, 175, 0.06) 0%, transparent 60% ), - linear-gradient(180deg, #06070d 0%, #05060a 60%, #04050a 100%); + linear-gradient(180deg, #050807 0%, #040706 60%, #030605 100%); } .scene-layer::before { @@ -520,8 +521,8 @@ a { opacity: 0.55; background-image: radial-gradient(1px 1px at 24px 32px, rgba(237, 239, 245, 0.85), transparent 60%), - radial-gradient(1px 1px at 112px 84px, rgba(182, 168, 255, 0.7), transparent 60%), - radial-gradient(1.4px 1.4px at 186px 42px, rgba(103, 232, 249, 0.7), transparent 60%), + radial-gradient(1px 1px at 112px 84px, rgba(170, 235, 208, 0.65), transparent 60%), + radial-gradient(1.4px 1.4px at 186px 42px, rgba(143, 230, 194, 0.6), transparent 60%), radial-gradient(1px 1px at 68px 148px, rgba(237, 239, 245, 0.55), transparent 60%), radial-gradient(1px 1px at 212px 164px, rgba(237, 239, 245, 0.5), transparent 60%); background-size: 236px 196px; @@ -542,8 +543,8 @@ a { position: absolute; inset: 44% -20% -20%; background-image: - linear-gradient(rgba(139, 124, 255, 0.16) 1px, transparent 1px), - linear-gradient(90deg, rgba(139, 124, 255, 0.11) 1px, transparent 1px); + linear-gradient(rgba(72, 160, 130, 0.15) 1px, transparent 1px), + linear-gradient(90deg, rgba(72, 160, 130, 0.1) 1px, transparent 1px); background-size: 64px 64px, 64px 64px; transform: perspective(520px) rotateX(66deg); transform-origin: 50% 0; @@ -572,10 +573,10 @@ a { border-radius: 1px 1px 0 0; background: linear-gradient( 180deg, - rgba(139, 124, 255, 0.52), - rgba(17, 20, 29, 0.08) 88% + rgba(72, 170, 136, 0.5), + rgba(12, 22, 18, 0.08) 88% ); - box-shadow: 0 0 24px rgba(139, 124, 255, 0.14); + box-shadow: 0 0 24px rgba(72, 170, 136, 0.14); } .scene-poster__glow { @@ -590,8 +591,8 @@ a { border-radius: 50%; background: radial-gradient( circle, - rgba(139, 124, 255, 0.22) 0%, - rgba(103, 232, 249, 0.06) 42%, + rgba(72, 170, 136, 0.2) 0%, + rgba(143, 230, 194, 0.06) 42%, transparent 68% ); filter: blur(20px); @@ -1079,10 +1080,10 @@ a { .hero__title { max-width: 1050px; font-family: var(--font-display-stack); - font-size: clamp(3.35rem, 8.7vw, 7.6rem); - font-weight: 400; - line-height: 1.03; - letter-spacing: -0.018em; + font-size: clamp(3.1rem, 8.2vw, 7.1rem); + font-weight: 500; + line-height: 0.99; + letter-spacing: -0.038em; color: var(--fg); } @@ -1107,13 +1108,16 @@ a { color: var(--fg); } +.hero__title em { + font-style: normal; + color: var(--emerald); + padding-right: 0.04em; +} + .hero__line--accent em { - font-style: italic; - background: var(--ghost-gradient); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - padding-right: 0.06em; + font-style: normal; + color: var(--emerald); + padding-right: 0.04em; } .hero__bottom { @@ -1268,14 +1272,15 @@ a { max-width: 760px; margin-top: 18px; font-family: var(--font-display-stack); - font-size: clamp(2.5rem, 5.1vw, 4.5rem); - line-height: 1.04; - letter-spacing: -0.018em; + font-size: clamp(2.4rem, 4.9vw, 4.2rem); + font-weight: 500; + line-height: 1.02; + letter-spacing: -0.032em; } .section-head__title em { - font-style: italic; - color: var(--violet-soft); + font-style: normal; + color: var(--emerald); } .section-head > div:last-child { diff --git a/app/layout.tsx b/app/layout.tsx index 48f89a4..f379569 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,8 @@ import type { Metadata, Viewport } from "next"; -import { - Instrument_Serif, - Inter_Tight, - JetBrains_Mono, -} from "next/font/google"; +import { Inter_Tight, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import "./styles/landing.css"; -const display = Instrument_Serif({ - subsets: ["latin"], - weight: "400", - style: ["normal", "italic"], - variable: "--font-display", - display: "swap", -}); - const sans = Inter_Tight({ subsets: ["latin"], variable: "--font-sans", @@ -75,7 +63,7 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: "#05060A", + themeColor: "#040A08", colorScheme: "dark", width: "device-width", initialScale: 1, @@ -89,7 +77,7 @@ export default function RootLayout({ return ( diff --git a/app/login/login.css b/app/login/login.css new file mode 100644 index 0000000..90d9d8b --- /dev/null +++ b/app/login/login.css @@ -0,0 +1,27 @@ +.login-main { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 32px; + text-align: center; +} +.login-title { + font-size: 2rem; + letter-spacing: 0.04em; +} +.login-note { opacity: 0.8; } +.login-error { color: #f87171; } +.login-btn { + padding: 12px 28px; + border: 1px solid rgba(128, 128, 160, 0.4); + border-radius: 10px; + color: inherit; + text-decoration: none; + font-size: 1rem; +} +.login-btn:hover { border-color: rgba(160, 160, 220, 0.9); } +.login-hint { opacity: 0.55; font-size: 0.85rem; max-width: 420px; line-height: 1.6; } +.login-hint code { font-size: 0.8rem; } diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..77eb017 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "./login.css"; + +export const metadata: Metadata = { title: "Sign in — GhostInTheCodebase" }; + +export default function LoginPage({ searchParams }: { searchParams?: { error?: string } }) { + const error = searchParams?.error; + return ( +
+

Ghost

+

Agent accountability for AI-built codebases.

+ {error ?

✗ {decodeURIComponent(error)}

: null} +
+ Sign in with GitHub + +

+ Only humans sign in here. Agents connect via device pairing — ghost setup. +

+
+ ); +} diff --git a/app/pair/page.tsx b/app/pair/page.tsx new file mode 100644 index 0000000..3f5018a --- /dev/null +++ b/app/pair/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import PairClient from "./pair-client"; +import "./pair.css"; + +export const metadata: Metadata = { title: "Pair device — GhostInTheCodebase" }; + +export default function PairPage({ searchParams }: { searchParams?: { code?: string } }) { + const code = (searchParams?.code ?? "").trim(); + return ( +
+

Ghost — device pairing

+ +
+ ); +} diff --git a/app/pair/pair-client.tsx b/app/pair/pair-client.tsx new file mode 100644 index 0000000..e46d23c --- /dev/null +++ b/app/pair/pair-client.tsx @@ -0,0 +1,65 @@ +"use client"; +// Pairing confirm UI — the human half of `ghost setup`. +import { useState } from "react"; + +export default function PairClient({ code }: { code: string }) { + const [status, setStatus] = useState<"idle" | "working" | "paired" | "unauth" | "error">("idle"); + const [message, setMessage] = useState(""); + + async function confirm() { + setStatus("working"); + try { + const res = await fetch("/api/v1/pair/confirm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }); + if (res.ok) { + setStatus("paired"); + return; + } + if (res.status === 401) { + setStatus("unauth"); + return; + } + const json = (await res.json().catch(() => ({}))) as { error?: string }; + setMessage(json.error ?? `error ${res.status}`); + setStatus("error"); + } catch (e) { + setMessage(e instanceof Error ? e.message : "network error"); + setStatus("error"); + } + } + + if (!code) { + return

No pairing code. Run ghost setup in your terminal.

; + } + + return ( +
+

+ A CLI on your machine wants to pair with your Ghost account. +

+
{code}
+ {status === "paired" ? ( +

✓ Device paired — token stored locally on that machine. You can close this tab.

+ ) : status === "unauth" ? ( +

+ + Sign in with GitHub + {" "} + to confirm the pairing. +

+ ) : status === "error" ? ( + <> +

✗ {message}

+ + + ) : ( + + )} +
+ ); +} diff --git a/app/pair/pair.css b/app/pair/pair.css new file mode 100644 index 0000000..7bdd276 --- /dev/null +++ b/app/pair/pair.css @@ -0,0 +1,50 @@ +.pair-main { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 32px; + text-align: center; +} +.pair-title { + font-size: 1.35rem; + letter-spacing: 0.02em; +} +.pair-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + max-width: 480px; +} +.pair-note { + opacity: 0.85; + line-height: 1.6; +} +.pair-note code, +.pair-code { + font-family: var(--font-mono, "JetBrains Mono", monospace); +} +.pair-code { + font-size: 1.6rem; + padding: 10px 22px; + border: 1px solid rgba(128, 128, 160, 0.35); + border-radius: 10px; + letter-spacing: 0.12em; +} +.pair-note.ok { color: #4ade80; } +.pair-note.bad { color: #f87171; } +.pair-link { text-decoration: underline; } +.pair-btn { + padding: 10px 24px; + border: 1px solid rgba(128, 128, 160, 0.4); + border-radius: 10px; + background: transparent; + color: inherit; + font-size: 0.95rem; + cursor: pointer; +} +.pair-btn:hover:not(:disabled) { border-color: rgba(160, 160, 220, 0.9); } +.pair-btn:disabled { opacity: 0.55; cursor: default; } diff --git a/app/styles/landing.css b/app/styles/landing.css index d64c97a..c9b1ecd 100644 --- a/app/styles/landing.css +++ b/app/styles/landing.css @@ -24,7 +24,7 @@ background: radial-gradient( 120% 90% at 50% 45%, transparent 42%, - rgba(5, 6, 10, 0.52) 100% + rgba(3, 7, 6, 0.55) 100% ); } @@ -264,12 +264,52 @@ background: linear-gradient( 90deg, - rgba(5, 6, 10, 0.92) 0%, - rgba(5, 6, 10, 0.66) 46%, - rgba(5, 6, 10, 0.18) 78%, - rgba(5, 6, 10, 0.5) 100% + rgba(3, 7, 6, 0.95) 0%, + rgba(3, 7, 6, 0.74) 32%, + rgba(3, 7, 6, 0.2) 62%, + rgba(3, 7, 6, 0.03) 84%, + rgba(3, 7, 6, 0.32) 100% ), - linear-gradient(180deg, var(--ink-base) 0%, transparent 16%, transparent 84%, var(--ink-base) 100%); + linear-gradient( + 180deg, + var(--ink-base) 0%, + transparent 13%, + transparent 88%, + var(--ink-base) 100% + ); +} + +.loop__sticky::before { + content: ""; + position: absolute; + top: -14%; + right: 8%; + z-index: -1; + width: 30vw; + height: 128%; + pointer-events: none; + background: linear-gradient( + 180deg, + transparent 0%, + rgba(143, 230, 194, 0.07) 28%, + rgba(143, 230, 194, 0.03) 58%, + transparent 88% + ); + filter: blur(34px); + transform: translate3d(0, calc(var(--loop, 0) * -5%), 0); +} + +.loop__head, +.loop__body { + will-change: transform; +} + +.loop__head { + transform: translate3d(0, calc(var(--loop, 0) * -22px), 0); +} + +.loop__body { + transform: translate3d(0, calc(var(--loop, 0) * 14px), 0); } .loop__head { @@ -283,17 +323,15 @@ .loop__head h2 { margin-top: 14px; font-family: var(--font-display-stack); - font-size: clamp(2.1rem, 4vw, 3.5rem); - line-height: 1.03; - letter-spacing: -0.018em; + font-size: clamp(2rem, 3.8vw, 3.3rem); + font-weight: 500; + line-height: 1.01; + letter-spacing: -0.032em; } .loop__head h2 em { - font-style: italic; - background: var(--ghost-gradient); - -webkit-background-clip: text; - background-clip: text; - color: transparent; + font-style: normal; + color: var(--emerald); } .loop__count { @@ -369,9 +407,10 @@ .loop-panel h3 { margin-top: 14px; font-family: var(--font-display-stack); - font-size: clamp(2.4rem, 4.6vw, 4rem); - line-height: 1.03; - letter-spacing: -0.018em; + font-size: clamp(2.2rem, 4.2vw, 3.6rem); + font-weight: 500; + line-height: 1.01; + letter-spacing: -0.032em; } .loop-panel p { @@ -429,6 +468,22 @@ pointer-events: none; } +.loop-ui.glass { + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.06) 0%, + rgba(255, 255, 255, 0.015) 100% + ), + rgba(5, 13, 11, 0.42); + backdrop-filter: blur(22px) saturate(118%); + -webkit-backdrop-filter: blur(22px) saturate(118%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 28px 70px -30px rgba(0, 0, 0, 0.85), + 0 0 0 1px rgba(143, 230, 194, 0.04); +} + .loop-ui[data-active="true"] { opacity: 1; transform: none; @@ -1482,14 +1537,15 @@ .quickstart__copy h2 { margin-top: 18px; font-family: var(--font-display-stack); - font-size: clamp(2.4rem, 4.4vw, 3.8rem); - line-height: 1.03; - letter-spacing: -0.018em; + font-size: clamp(2.3rem, 4.2vw, 3.6rem); + font-weight: 500; + line-height: 1.01; + letter-spacing: -0.032em; } .quickstart__copy h2 em { - font-style: italic; - color: var(--violet-soft); + font-style: normal; + color: var(--emerald); } .quickstart__copy p { @@ -1689,17 +1745,15 @@ max-width: 980px; margin-top: 22px; font-family: var(--font-display-stack); - font-size: clamp(3.4rem, 8.2vw, 7.4rem); - line-height: 1.02; - letter-spacing: -0.018em; + font-size: clamp(3.1rem, 7.8vw, 6.9rem); + font-weight: 500; + line-height: 0.99; + letter-spacing: -0.038em; } .final__title em { - font-style: italic; - background: var(--ghost-gradient); - -webkit-background-clip: text; - background-clip: text; - color: transparent; + font-style: normal; + color: var(--emerald); } .final__sub { diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..43c31c9 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,99 @@ +-- GhostInTheCodebase — Ghost Cloud schema (Neon PostgreSQL) +-- Runs with: psql $DATABASE_URL -f db/schema.sql (idempotent-safe to re-run pieces) + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + github_id text UNIQUE NOT NULL, + login text NOT NULL, + name text, + email text, + avatar_url text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL DEFAULT 'device', + token_hash text UNIQUE NOT NULL, -- sha256 of the raw token; raw never stored + revoked boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz +); +CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id); + +CREATE TABLE IF NOT EXISTS pairing_codes ( + code text PRIMARY KEY, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + raw_token text, -- populated on bind; returned once by CLI poll + bound_at timestamptz, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +DELETE FROM pairing_codes WHERE expires_at < now(); -- lazy sweep on install + +CREATE TABLE IF NOT EXISTS projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + repo text, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, name) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + external_ref text, -- local recorder session id + status text NOT NULL DEFAULT 'recorded', + head text, + started_at timestamptz, + ended_at timestamptz, + sync text NOT NULL DEFAULT 'off', + payload jsonb NOT NULL, -- policy-gated snapshot (scrubbed) + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS turns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + turn_index int, + external_id text, + estimated boolean NOT NULL DEFAULT false, + agent text, + prompt text, -- present only when sync=FULL (consented) + started_at timestamptz, + ended_at timestamptz, + files jsonb +); +CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index); + +CREATE TABLE IF NOT EXISTS checkpoints ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + turn_index int, + sha text, + files jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS metric_samples ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + turn_index int, + captured_at timestamptz, + probe_failed boolean NOT NULL DEFAULT false, + metrics jsonb +); +CREATE INDEX IF NOT EXISTS idx_samples_session ON metric_samples(session_id, captured_at); + +CREATE TABLE IF NOT EXISTS anomalies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + type text, + severity text, + evidence jsonb +); diff --git a/package-lock.json b/package-lock.json index 9b4f758..9e71be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "@react-three/drei": "9.122.0", "@react-three/fiber": "8.17.10", "next": "14.2.35", + "pg": "^8.13.1", "react": "18.3.1", "react-dom": "18.3.1", "three": "0.169.0" }, "devDependencies": { "@types/node": "^20.14.0", + "@types/pg": "^8.11.10", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/three": "0.169.0", @@ -96,9 +98,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -115,9 +114,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -134,9 +130,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -153,9 +146,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -469,6 +459,18 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -933,6 +935,95 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -967,6 +1058,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -1110,6 +1240,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -1353,6 +1492,15 @@ "node": ">= 8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zustand": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", diff --git a/package.json b/package.json index 0e09efe..42d402c 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,22 @@ "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "ghost": "node packages/ghost/ghost.mjs" }, "dependencies": { "@phosphor-icons/react": "^2.1.7", "@react-three/drei": "9.122.0", "@react-three/fiber": "8.17.10", "next": "14.2.35", + "pg": "^8.13.1", "react": "18.3.1", "react-dom": "18.3.1", "three": "0.169.0" }, "devDependencies": { "@types/node": "^20.14.0", + "@types/pg": "^8.11.10", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/three": "0.169.0", diff --git a/packages/ghost/ghost.mjs b/packages/ghost/ghost.mjs new file mode 100644 index 0000000..2a8f079 --- /dev/null +++ b/packages/ghost/ghost.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +// Ghost — local recorder & CLI. +// Commands: setup · connect opencode · watch · status · sessions · pair-status +// Philosophy: recording works with zero accounts (Mode B/C); sync is opt-in. + +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import * as shadow from "./lib/shadowgit.mjs"; +import * as store from "./lib/store.mjs"; +import { createProbes } from "./lib/probes.mjs"; + +const ROOT = process.cwd(); +const GHOST_DIR = path.join(ROOT, ".ghost"); +const DEFAULT_RECORDER_PORT = 5417; + +// ── helpers ────────────────────────────────────────────────────────────────── +const readConfig = () => { + try { return JSON.parse(fs.readFileSync(path.join(GHOST_DIR, "config.json"), "utf8")); } + catch { return { cloudUrl: "https://ghostinthecodebase.vercel.app", sync: "off", recorderPort: DEFAULT_RECORDER_PORT }; } +}; +const writeConfig = (c) => { fs.mkdirSync(GHOST_DIR, { recursive: true }); fs.writeFileSync(path.join(GHOST_DIR, "config.json"), JSON.stringify(c, null, 2)); }; +const readCreds = () => { try { return JSON.parse(fs.readFileSync(path.join(GHOST_DIR, "credentials.json"), "utf8")); } catch { return null; } }; +const arg = (name, fallback) => { + const i = process.argv.indexOf(name); + return i > 0 ? process.argv[i + 1] : fallback; +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── ghost setup (device pairing → Cloud) ───────────────────────────────────── +async function cmdSetup() { + const config = readConfig(); + const base = arg("--cloud", config.cloudUrl || "https://ghostinthecodebase.vercel.app"); + const res = await fetch(`${base}/api/v1/pair`, { method: "POST" }); + if (!res.ok) { console.error(`✗ cloud unreachable (${res.status}) — run local-only, or check GHOST_API_URL`); process.exit(1); } + const { code, expiresAt } = await res.json(); + const url = `${base}/pair?code=${code}`; + console.log(`\n Pair your device with Ghost Cloud`); + console.log(` 1) Open: ${url}`); + console.log(` 2) Sign in with GitHub and confirm the code: ${code}`); + console.log(` 3) This window is waiting (${expiresAt ?? "10 min"} window)...\n`); + tryOpenBrowser(url); + // poll until bound + for (let i = 0; i < 180; i++) { + await sleep(2000); + const r = await fetch(`${base}/api/v1/pair?code=${code}`); + if (r.ok) { + const st = await r.json(); + if (st.status === "bound") { + fs.mkdirSync(GHOST_DIR, { recursive: true }); + const creds = readCreds() || {}; + fs.writeFileSync(path.join(GHOST_DIR, "credentials.json"), JSON.stringify({ deviceToken: st.deviceToken, cloudUrl: base }, null, 2)); + writeConfig({ ...config, cloudUrl: base, sync: config.sync === "off" ? "on" : config.sync }); + console.log(` ✓ Device paired — token stored in .ghost/credentials.json (never commit it)`); + console.log(` ✓ Sync is now ON (policy: metadata+metrics+summaries; source code never)`); + return; + } + } + } + console.error(" ✗ Timed out waiting for pairing confirmation"); + process.exit(1); +} + +function tryOpenBrowser(url) { + try { + if (process.platform === "win32") spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); + else spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); + } catch {} +} + +// ── ghost connect opencode ─────────────────────────────────────────────────── +function cmdConnectOpencode() { + const src = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "opencode-plugin", "index.ts"); + const dest = path.join(ROOT, ".opencode", "plugins", "ghost"); + fs.mkdirSync(dest, { recursive: true }); + fs.copyFileSync(src, path.join(dest, "index.ts")); + writeConfig({ ...readConfig(), recorderPort: DEFAULT_RECORDER_PORT }); + console.log(` ✓ OpenCode adapter installed at .opencode/plugins/ghost/index.ts`); + console.log(` (OpenCode auto-loads it next session; recording also works WITHOUT it — Mode B/C)`); +} + +// ── recorder core ──────────────────────────────────────────────────────────── +async function cmdWatch() { + const repo = path.resolve(arg("--repo", ROOT)); + const appUrl = arg("--app-url", `http://localhost:${arg("--port", "3000")}`); + const healthPath = arg("--health-path", store.DEFAULT_HEALTH_PATH ?? "/api/health"); + const config = readConfig(); + const creds = readCreds(); + const sync = arg("--sync", config.sync && creds ? config.sync : "off"); + + const session = store.newSession({ repo, appUrl, sync }); + session.head = shadow.currentHead(repo); + + // active turn tracking + let turnNumber = 0; + const currentTurn = () => turnNumber; + const startTurn = (data = {}) => { + turnNumber += 1; + session.turns.push({ + index: turnNumber, + id: data.turnId ?? null, + estimated: !data.turnId, + agent: data.agent ?? null, + prompt: data.prompt ? store.scrub(String(data.prompt)).slice(0, 2000) : undefined, + startedAt: data.ts ?? new Date().toISOString(), + endedAt: null, + files: [], + }); + return session.turns[session.turns.length - 1]; + }; + const activeTurn = () => session.turns.find((t) => t.index === turnNumber) ?? null; + + // Mode C — runtime monitor + const probes = createProbes({ url: appUrl.replace(/\/$/, "") + healthPath, intervalMs: 4000 }); + probes.sink = (sample) => session.samples.push({ ts: sample.ts, turnNumber: currentTurn(), metrics: sample.metrics, probeFailed: sample.probeFailed }); + if (!arg("--no-probes", false)) probes.start(); + + // Mode A/B — event receiver for the OpenCode adapter (loopback only) + const port = Number(arg("--recorder-port", config.recorderPort || DEFAULT_RECORDER_PORT)); + const eventsBase = `http://127.0.0.1:${port}`; + const emitter = http.createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/events") { res.writeHead(404).end(); return; } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + res.writeHead(204).end(); + try { + const { type, data = {}, ts } = JSON.parse(body); + const stamp = ts ?? new Date().toISOString(); + if (type === "turn.started") startTurn({ ...data, ts: stamp }); + else if (type === "turn.completed") { + const t = activeTurn(); + if (t) { t.endedAt = stamp; if (data.turnId && !t.id) { t.id = data.turnId; t.estimated = false; } } + checkpoint(`turn ${turnNumber} completed`); + } else if (type === "turn.meta") { + const t = activeTurn(); + if (t) t.files = [...new Set([...(t.files ?? []), ...(data.files ?? [])])]; + } + } catch { /* malformed event — recorder stays resilient */ } + }); + }); + await new Promise((r) => emitter.listen(port, "127.0.0.1", r)); + + // Mode B — filesystem watcher (works with ANY agent, no events needed) + let burst = null; + const checkpoint = (label) => { + try { + const snap = shadow.commitSnapshot(repo, `ghost: ${label}`); + if (!snap.diff) return; + const dir = store.sessionDir(ROOT, session); + fs.mkdirSync(path.join(dir, "diffs"), { recursive: true }); + fs.writeFileSync(path.join(dir, "diffs", `${snap.sha.slice(0, 12)}.patch`), snap.diff); + session.checkpoints.push({ ts: new Date().toISOString(), turnNumber: currentTurn(), sha: snap.sha, files: snap.changedFiles, diffRef: `${session.id}/diffs/${snap.sha.slice(0, 12)}.patch` }); + const t = activeTurn(); // attribute checkpoint against the open turn + } catch (e) { console.error(" ! checkpoint failed:", e.message); } + }; + let watching; + try { watching = fs.watch(repo, { recursive: true }, (ev, file) => { + if (!file) return; + const p = file.split(path.sep)[0]; + if (["node_modules", ".next", ".git", ".ghost", "out", ".vercel", "testsprite_tests"].includes(p)) return; + if (burst) clearTimeout(burst); + burst = setTimeout(() => { + burst = null; + // no open turn? attribute as estimated turn so nothing is lost + if (!activeTurn()) startTurn({ ts: new Date().toISOString() }); + checkpoint("worktree burst"); + const t = activeTurn(); if (t && !t.endedAt) t.endedAt = new Date().toISOString(); + }, 900); + }); } catch { console.error(" ! recursive watch unavailable on this FS — adapter events only"); } + + console.log(`\n ● Ghost recorder is live`); + console.log(` session ${session.id}`); + console.log(` repo ${repo}`); + console.log(` app ${appUrl}`); + console.log(` adapter ${eventsBase}/events (OpenCode plugin target)`); + console.log(` sync ${sync}${sync === "off" ? " (local-only) — run: ghost setup to enable" : ""}`); + console.log(` press Ctrl+C to stop & save\n`); + + const finalize = async () => { + probes.stop(); + try { watching?.close(); } catch {} + if (activeTurn()?.endedAt == null && turnNumber >= 1) activeTurn().endedAt = new Date().toISOString(); + store.endSession(session); + store.persist(ROOT, session); + if (sync !== "off") { + store.queueSync(ROOT, session); + try { + const n = await store.flushOutbox(ROOT, { ...config, cloudUrl: (readCreds()?.cloudUrl) || config.cloudUrl, deviceToken: readCreds()?.deviceToken }); + console.log(n ? ` ↥ synced ${n} session(s) to Ghost Cloud` : ""); + } catch (e) { console.log(" (queued for later sync — cloud unreachable)"); } + } + console.log(`\n ✓ saved .ghost/sessions/${session.id}/session.json`); + console.log(` replay: open /playground → load session → pick this file\n`); + process.exit(0); + }; + process.on("SIGINT", finalize); + process.on("SIGTERM", finalize); + // keep alive + await new Promise(() => {}); +} + +// ── status & list ──────────────────────────────────────────────────────────── +function cmdStatus() { + const config = readConfig(); + const creds = readCreds(); + const head = shadow.currentHead(ROOT); + console.log(`repo ${ROOT}`); + console.log(`head ${head ? head.slice(0, 10) : "(no git)"}`); + console.log(`cloud ${config.cloudUrl}${creds ? " (paired)" : " (NOT paired)"}`); + console.log(`sync ${creds ? config.sync : "off (local-only)"}`); + console.log(`settings .ghost/config.json — secrets handled per README policy`); +} +function cmdSessions() { + const list = store.listSessions(ROOT); + if (!list.length) { console.log("no sessions yet — run: npm run ghost -- watch"); return; } + for (const s of list) console.log(`${new Date(s.mtime).toISOString()} ${s.id}`); +} + +// ── entry ──────────────────────────────────────────────────────────────────── +const cmd0 = process.argv[2] ?? "help"; +const help = () => { + console.log(`ghost — local recorder for AI coding agents + + node packages/ghost/ghost.mjs setup pair this machine with Ghost Cloud + node packages/ghost/ghost.mjs connect opencode install the OpenCode adapter + node packages/ghost/ghost.mjs watch [--repo .] [--app-url http://localhost:3000] [--sync on|off] + node packages/ghost/ghost.mjs status + node packages/ghost/ghost.mjs sessions`); +}; +if (cmd0 === "setup") await cmdSetup(); +else if (cmd0 === "connect") { if (process.argv[3] === "opencode") cmdConnectOpencode(); else help(); } +else if (cmd0 === "watch") await cmdWatch(); +else if (cmd0 === "status") cmdStatus(); +else if (cmd0 === "sessions") cmdSessions(); +else help(); diff --git a/packages/ghost/lib/probes.mjs b/packages/ghost/lib/probes.mjs new file mode 100644 index 0000000..707ab94 --- /dev/null +++ b/packages/ghost/lib/probes.mjs @@ -0,0 +1,53 @@ +// Ghost — runtime monitor (Mode C): polls the app's health probe and emits +// MetricSample objects tagged with the active turn. Probe failures count as +// errors but never crash the recorder. + +export const DEFAULT_HEALTH_PATH = "/api/health"; + +export function createProbes({ url, intervalMs = 4000, fetchImpl = globalThis.fetch }) { + const state = { running: false, timer: null, out: (s) => {} }; + let lastCpu = null; + + async function sample() { + const ts = new Date().toISOString(); + try { + const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5000) }); + if (!res.ok) return push(ts, { errors: 1, httpStatus: res.status }, true); + const body = await res.json().catch(() => ({})); + const cpu = Number(body.cpuPercent ?? body.cpu ?? 0) || 0; + let cpuDelta = cpu; + if (lastCpu !== null) cpuDelta = Math.max(0, cpu - lastCpu); + lastCpu = cpu; + push(ts, { + cpu: Number((cpuDelta * 100 + cpu).toFixed(2)), + rss: Number(body.rss ?? 0), + heap: Number(body.heap ?? 0), + latencyMs: null, + errors: 0, + uptime: Number(body.uptime ?? 0), + }, false); + } catch { + push(ts, { errors: 1, httpStatus: 0 }, true); + } + } + + function push(ts, metrics, failed) { + state.out({ ts, metrics, probeFailed: failed }); + } + + return { + // the recorder injects the active turn as samples stream out + set sink(fn) { state.out = fn; }, + start() { + if (state.running) return; + state.running = true; + void sample(); + state.timer = setInterval(() => void sample(), intervalMs); + state.timer.unref?.(); + }, + stop() { + if (state.timer) clearInterval(state.timer); + state.running = false; + }, + }; +} diff --git a/packages/ghost/lib/shadowgit.mjs b/packages/ghost/lib/shadowgit.mjs new file mode 100644 index 0000000..4d37e8f --- /dev/null +++ b/packages/ghost/lib/shadowgit.mjs @@ -0,0 +1,61 @@ +// Ghost — shadow-git: checkpoints & diffs WITHOUT moving HEAD or touching branches. +// Technique: stage changes into a throwaway index (GIT_INDEX_FILE env), write-tree, +// commit-tree. The worktree and the user's branches are never modified. + +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs"; + +const IGNORE_SET = new Set([ + "node_modules", ".next", ".git", ".ghost", "out", ".vercel", + "testsprite_tests", ".turbo", "dist", +]); + +/** + * Create a shadow commit of the current worktree state. + * Returns { sha, diff } — sha is a real git commit object (not on any branch), + * diff is a unified diff patch versus HEAD ("" when the worktree matches HEAD). + */ +export function commitSnapshot(repo, message = "ghost checkpoint") { + const idx = path.join(repo, ".ghost", "shadow-index"); + fs.mkdirSync(path.dirname(idx), { recursive: true }); + const env = { ...process.env, GIT_INDEX_FILE: idx }; + + const git = (args, extraEnv = {}) => + execFileSync("git", args, { cwd: repo, env: { ...env, ...extraEnv }, encoding: "utf8" }).trim(); + + try { git(["read-tree", "HEAD"]); } catch { /* empty repo without HEAD */ } + git(["add", "-A"]); + const tree = git(["write-tree"]); + let parent = null; + try { parent = git(["rev-parse", "HEAD"]); } catch { /* first snapshot */ } + const commitArgs = parent ? ["commit-tree", tree, "-p", parent, "-m", message] : ["commit-tree", tree, "-m", message]; + const sha = git(commitArgs, { GIT_AUTHOR_DATE: new Date().toISOString(), GIT_COMMITTER_DATE: new Date().toISOString() }); + const diffOut = execFileSync( + "git", ["diff", "--binary", ...(parent ? [parent] : ["4b825dc642cb6eb9a060e54bf8d69288fbee4904"]), sha], + { cwd: repo, encoding: "utf8", maxBuffer: 1024 * 1024 * 16 }, + ).trim(); + return { sha, diff: diffOut, changedFiles: listChangedFiles(repo, parent ? parent : "4b825dc642cb6eb9a060e54bf8d69288fbee4904", sha) }; +} + +function listChangedFiles(repo, from, to) { + try { + const out = execFileSync("git", ["diff", "--name-only", from, to], { cwd: repo, encoding: "utf8" }).trim(); + return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : []; + } catch { return []; } +} + +/** Roll back ONE file to a snapshot (worktree + index for that file only). */ +export function rollbackFile(repo, sha, filePath) { + execFileSync("git", ["checkout", sha, "--", filePath], { cwd: repo, encoding: "utf8" }); +} + +/** Fetch a file's content at a snapshot. */ +export function showFile(repo, sha, filePath) { + return execFileSync("git", ["show", `${sha}:${filePath}`], { cwd: repo, encoding: "utf8" }); +} + +export function currentHead(repo) { + try { return execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim(); } + catch { return null; } +} diff --git a/packages/ghost/lib/store.mjs b/packages/ghost/lib/store.mjs new file mode 100644 index 0000000..a4c5abe --- /dev/null +++ b/packages/ghost/lib/store.mjs @@ -0,0 +1,120 @@ +// Ghost — session manifest (contract v1), local persistence, secret scrubbing, +// and policy-gated sync to Ghost Cloud. + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +export const CONTRACT_VERSION = 1; + +/** Policy: secrets never sync, ever. Source code content likewise stays local. */ +const SECRET_PATTERNS = [ + [/sk-(?:[A-Za-z0-9_-]{16,}|member-[A-Za-z0-9_-]{10,})/g, "[redacted:key]"], + [/gh[pousr]_[A-Za-z0-9]{20,}/g, "[redacted:gh-token]"], + [/AKIA[0-9A-Z]{16}/g, "[redacted:aws]"], + [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted:pem]"], + [/"(?:secret|token|password|api[_-]?key)"\s*:\s*"[^"]{4,}"/gi, '"[redacted]"'], + [/[A-Za-z0-9+/]{40,}={0,2}>/g, (m) => (m.length > 64 ? "[redacted:blob]" : m)], +]; + +export function scrub(text) { + if (typeof text !== "string") return text; + for (const [re, rep] of SECRET_PATTERNS) text = text.replace(re, rep); + return text; +} + +export function newSession({ repo, appUrl, sync }) { + return { + v: CONTRACT_VERSION, + id: crypto.randomUUID(), + startedAt: new Date().toISOString(), + endedAt: null, + status: "recording", + repo: path.basename(repo), + repoPath: repo, + app: { baseUrl: appUrl }, + sync, + head: null, + turns: [], // {index, id?, estimated, prompt?(scrubbed), startedAt, endedAt, files} + samples: [], // {ts, turnNumber, metrics, probeFailed} + checkpoints: [],// {ts, turnNumber, sha, files, diffRef} + }; +} + +export function endSession(s) { + s.endedAt = new Date().toISOString(); + s.status = "recorded"; + return s; +} + +export function sessionDir(root, session) { + return path.join(root, ".ghost", "sessions", session.id); +} + +export function persist(root, session) { + const dir = sessionDir(root, session); + fs.mkdirSync(dir, { recursive: true }); + const copy = JSON.parse(JSON.stringify(session)); + copy.repoPath = undefined; // local path stays out of anything syncable + fs.writeFileSync(path.join(dir, "session.json"), JSON.stringify(scrub(JSON.stringify(copy)), null, 2)); + return dir; +} + +export function listSessions(root) { + const base = path.join(root, ".ghost", "sessions"); + if (!fs.existsSync(base)) return []; + return fs.readdirSync(base).flatMap((id) => { + try { + return [{ id, mtime: fs.statSync(path.join(base, id, "session.json")).mtimeMs }]; + } catch { return []; } + }).sort((a, b) => b.mtime - a.mtime); +} + +export function loadSession(root, id) { + return JSON.parse(fs.readFileSync(path.join(root, ".ghost", "sessions", id, "session.json"), "utf8")); +} + +/** Outbox: pending syncs survive crashes; flushed when the cloud is reachable. */ +export function queueSync(root, session) { + const q = path.join(root, ".ghost", "outbox"); + fs.mkdirSync(q, { recursive: true }); + fs.writeFileSync(path.join(q, `${session.id}.json`), JSON.stringify(session)); +} + +export function pendingSyncs(root) { + const q = path.join(root, ".ghost", "outbox"); + if (!fs.existsSync(q)) return []; + return fs.readdirSync(q).filter((f) => f.endsWith(".json")).map((f) => path.join(q, f)); +} + +export async function flushOutbox(root, config) { + const files = pendingSyncs(root); + for (const file of files) { + const session = JSON.parse(fs.readFileSync(file, "utf8")); + await syncSession(config, session); + fs.rmSync(file, { force: true }); + } + return files.length; +} + +/** POST one session to Ghost Cloud. MetadataPolicy applies here. */ +export async function syncSession(config, session) { + if (!config.deviceToken || config.sync === "off") return false; + const payload = preparePayload(session); + const res = await fetch(`${config.cloudUrl}/api/v1/sessions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${config.deviceToken}` }, + body: JSON.stringify(payload), + }); + return res.ok; +} + +function preparePayload(session) { + const copy = JSON.parse(JSON.stringify(session)); + copy.repoPath = undefined; + if (copy.sync !== "full") { // consent-gated: no prompts, no diffs leave the machine + for (const t of copy.turns ?? []) t.prompt = undefined; + for (const c of copy.checkpoints ?? []) c.diffRef = undefined; + } + return scrub(JSON.stringify(copy)) === JSON.stringify(copy) ? copy : JSON.parse(scrub(JSON.stringify(copy))); +} diff --git a/packages/opencode-plugin/index.ts b/packages/opencode-plugin/index.ts new file mode 100644 index 0000000..ca3820b --- /dev/null +++ b/packages/opencode-plugin/index.ts @@ -0,0 +1,62 @@ +// GhostInTheCodebase — OpenCode adapter (Mode A) +// Loads as an OpenCode plugin (`.opencode/plugins/ghost/index.ts` auto-load or +// published as @ghostinthecodebase/opencode). Streams session/turn/file events +// to the local Ghost recorder (default http://127.0.0.1:5417/events). +// +// Recording degrades gracefully: if this plugin is absent, Ghost Mode B/C still +// records from the filesystem + runtime (turn ids are then marked estimated). + +import { Plugin } from "@opencode/plugin" + +const RECORDER = process.env.GHOST_RECORDER_URL ?? "http://127.0.0.1:5417" +const DEBUG = process.env.GHOST_DEBUG === "1" + +async function post(type, data = {}) { + try { + await fetch(`${RECORDER}/events`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type, data, ts: new Date().toISOString() }), + signal: AbortSignal.timeout(2000), + }) + } catch (e) { + if (DEBUG) console.error(`[ghost] ${type} -> ${e?.message ?? e}`) + } +} + +export default Plugin.define({ + id: "ghost", + async setup(ctx) { + if (DEBUG) console.error(`[ghost] adapter loaded — project ${ctx.location?.project?.canonical ?? "?"}`) + + // 1) Prompt admission — the authoritative turn boundary (Mode A identity). + let registration = await ctx.session.hook("prompt", (event) => { + void post("turn.started", { + turnId: `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + prompt: event.prompt.text, + }) + }) + + // 2) Turn end + file metadata — normalize the server event stream. + const controller = new AbortController() + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + const type = String(event?.type ?? "") + if (DEBUG) console.error(`[ghost] ev ${type}`) + // agent loop finished a response → turn.completed; recorder checkpoints + if (type === "session.idle" || type === "message.updated") { + void post("turn.completed") + } + } + } catch (e) { + if (DEBUG) console.error(`[ghost] stream: ${e?.message ?? e}`) + } + })() + + return () => { + controller.abort() + registration?.dispose?.() + } + }, +}) diff --git a/scripts/apply-schema.mjs b/scripts/apply-schema.mjs new file mode 100644 index 0000000..79d28f6 --- /dev/null +++ b/scripts/apply-schema.mjs @@ -0,0 +1,19 @@ +// One-shot: apply db/schema.sql to DATABASE_URL (idempotent, CREATE IF NOT EXISTS). +import fs from "node:fs"; +import pg from "pg"; + +const url = (process.env.DATABASE_URL ?? "").replace(/[?&]channel_binding=[^&]*/g, ""); +const pool = new pg.Pool({ connectionString: url, ssl: { rejectUnauthorized: false } }); +try { + const sql = fs.readFileSync("db/schema.sql", "utf8"); + await pool.query(sql); + const { rows } = await pool.query( + `SELECT table_name FROM information_schema.tables WHERE table_schema='public' ORDER BY 1`, + ); + console.log("schema OK →", rows.map((r) => r.table_name).join(", ")); +} catch (e) { + console.error("SCHEMA ERR:", e.message); + process.exitCode = 1; +} finally { + await pool.end(); +} diff --git a/src/components/site/GhostCanvas.tsx b/src/components/site/GhostCanvas.tsx index 3d8ef4d..4e9a709 100644 --- a/src/components/site/GhostCanvas.tsx +++ b/src/components/site/GhostCanvas.tsx @@ -4,11 +4,8 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber"; import type { MutableRefObject } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import * as THREE from "three"; -import { - sampleChoreography, - type Quality, -} from "./cityChoreography"; -import { makeCity, type Tower } from "./cityData"; +import { sampleChoreography, type Quality } from "./cityChoreography"; +import { CITY_LAYOUT, makeCity, type Tower } from "./cityData"; import type { ScrollSignals } from "./scroll"; interface Props { @@ -28,7 +25,11 @@ const QUALITY_DPR: Record = { low: [0.75, 1], }; -const FOG_COLOR = "#05060a"; +const FOG_COLOR = "#040a08"; + +/* -------------------------------------------------------------------------- */ +/* Towers */ +/* -------------------------------------------------------------------------- */ const TOWER_VERTEX = /* glsl */ ` uniform float uReveal; @@ -36,35 +37,50 @@ const TOWER_VERTEX = /* glsl */ ` attribute float aHealth; attribute float aTurn; attribute float aFlag; + attribute float aKind; + attribute float aTint; + attribute float aLit; + attribute float aGlow; varying float vSeed; varying float vHealth; varying float vFlag; varying float vFogDepth; - varying vec3 vNormalW; - varying vec3 vViewDir; + varying float vKind; + varying float vTint; + varying float vLit; + varying float vGlow; + varying vec3 vNormalL; varying vec3 vWorld; varying vec3 vLocal; + varying vec3 vScale; void main() { - vLocal = position; vSeed = aSeed; vHealth = aHealth; vFlag = aFlag; + vKind = aKind; + vTint = aTint; + vLit = aLit; + vGlow = aGlow; + vLocal = position; + vNormalL = normal; - float rise = smoothstep(aTurn - 0.95, aTurn + 0.12, uReveal); + float rise = smoothstep(aTurn - 1.15, aTurn - 0.05, uReveal); vec3 p = position; p.y *= mix(0.001, 1.0, rise); - vec4 instancePosition = instanceMatrix * vec4(p, 1.0); - vec4 world = modelMatrix * instancePosition; + vScale = vec3( + length(instanceMatrix[0].xyz), + length(instanceMatrix[1].xyz), + length(instanceMatrix[2].xyz) + ); + + vec4 world = modelMatrix * instanceMatrix * vec4(p, 1.0); vec4 view = viewMatrix * world; vWorld = world.xyz; vFogDepth = -view.z; - vNormalW = normalize(mat3(modelMatrix) * mat3(instanceMatrix) * normal); - vViewDir = normalize(cameraPosition - world.xyz); - gl_Position = projectionMatrix * view; } `; @@ -76,6 +92,7 @@ const TOWER_FRAGMENT = /* glsl */ ` uniform float uDim; uniform vec3 uAccentA; uniform vec3 uAccentB; + uniform vec3 uAccentC; uniform vec3 uAnomalyColor; uniform vec3 uFogColor; uniform float uFogDensity; @@ -84,51 +101,137 @@ const TOWER_FRAGMENT = /* glsl */ ` varying float vHealth; varying float vFlag; varying float vFogDepth; - varying vec3 vNormalW; - varying vec3 vViewDir; + varying float vKind; + varying float vTint; + varying float vLit; + varying float vGlow; + varying vec3 vNormalL; varying vec3 vWorld; varying vec3 vLocal; + varying vec3 vScale; - void main() { - float fresnel = pow( - 1.0 - clamp(abs(dot(normalize(vNormalW), normalize(vViewDir))), 0.0, 1.0), - 2.2 - ); - - float vertical = clamp(vLocal.y, 0.0, 1.0); - vec3 base = mix(vec3(0.055, 0.065, 0.1), uAccentA * 0.46, smoothstep(0.0, 1.0, vertical)); - - float cellX = fract((vWorld.x + vWorld.z) * 0.62 + vSeed * 17.0); - float cellY = fract(vWorld.y * 1.35 + vSeed * 9.0); - float window = step(0.56, cellX) * step(0.46, cellY); - float flicker = 0.55 + 0.45 * sin(uTime * (0.6 + vSeed * 2.2) + vSeed * 31.0); - vec3 windowColor = mix(uAccentA, uAccentB, step(0.5, fract(vSeed * 9.0))); - base += window * windowColor * (0.42 + 0.7 * flicker) * (0.45 + 0.55 * vertical); - base += windowColor * pow(vertical, 3.2) * 0.12; - - base += 0.04 * sin((vWorld.y - uTime * 0.55) * 9.0); + float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); + } - float edgeX = smoothstep(0.46, 0.5, abs(vLocal.x)); - float edgeZ = smoothstep(0.46, 0.5, abs(vLocal.z)); - base += (edgeX + edgeZ) * 0.018; + void main() { + vec3 n = normalize(vNormalL); + vec3 viewDir = normalize(cameraPosition - vWorld); + float facing = clamp(abs(dot(n, viewDir)), 0.0, 1.0); + float fres = pow(1.0 - facing, 2.6); + + float up = clamp(vLocal.y, 0.0, 1.0); + vec3 color = vec3(0.02, 0.03, 0.026); + + if (n.y > 0.55) { + /* Roof: dark slab with a faint lit parapet. */ + float edge = min( + min(vLocal.x + 0.5, 0.5 - vLocal.x), + min(vLocal.z + 0.5, 0.5 - vLocal.z) + ); + float rim = 1.0 - smoothstep(0.0, 0.085, edge); + color = mix(vec3(0.016, 0.026, 0.023), uAccentA * 0.32, 0.4 + vTint * 0.4); + color += uAccentB * rim * 0.085; + color += uAccentB * 0.014 * hash21(floor(vLocal.xz * 24.0 + vSeed * 12.0)); + } else if (n.y < -0.55) { + color = vec3(0.01, 0.016, 0.014); + } else { + float face = 0.0; + vec2 uv; + if (abs(n.x) > 0.5) { + uv = vec2(vLocal.z * vScale.z, vLocal.y * vScale.y); + face = n.x > 0.0 ? 1.0 : 2.0; + } else { + uv = vec2(vLocal.x * vScale.x, vLocal.y * vScale.y); + face = n.z > 0.0 ? 3.0 : 4.0; + } - float pulse = 0.5 + 0.5 * sin(uTime * 3.4); - vec3 hot = uAnomalyColor * (0.65 + 0.35 * pulse); - float flagGlow = vFlag * (0.32 + 0.16 * sin(uTime * 2.1)); - base = mix(base, hot, clamp(uAnomaly * vFlag * 0.86 + flagGlow, 0.0, 0.9)); - base += uAnomalyColor * vFlag * fresnel * 0.35; + /* Window grid in metres so every building reads at the same scale. */ + float cellW = 0.7; + float floorH = 0.8; + vec2 g = vec2(uv.x / cellW, uv.y / floorH); + vec2 cell = floor(g); + vec2 f = fract(g); + + float win = smoothstep(0.17, 0.25, f.x) * (1.0 - smoothstep(0.75, 0.83, f.x)); + win *= smoothstep(0.2, 0.3, f.y) * (1.0 - smoothstep(0.72, 0.82, f.y)); + + /* Ground floor reads as a taller storefront. */ + float ground = 1.0 - smoothstep(0.72, 1.05, uv.y); + vec2 gf = fract(vec2(uv.x / 1.15, uv.y / 1.05)); + float store = smoothstep(0.05, 0.12, gf.x) * (1.0 - smoothstep(0.88, 0.95, gf.x)); + store *= smoothstep(0.14, 0.22, gf.y) * (1.0 - smoothstep(0.66, 0.76, gf.y)); + win = mix(win, store, ground * 0.9); + + float h1 = hash21(cell + vec2(face * 37.1, vSeed * 113.0)); + float h2 = hash21(cell.yx + vec2(vSeed * 71.0, face * 13.7)); + float floorSeed = hash21(vec2(cell.y, face + vSeed * 9.0)); + + float litChance = 0.14 + vLit * 0.62; + float on = step(1.0 - litChance, h1) * step(0.16, floorSeed); + + float warmth = step(0.74, h2); + vec3 winCol = mix(uAccentB, vec3(1.0, 0.83, 0.58), warmth); + winCol = mix(winCol, vec3(0.8, 0.95, 1.0), step(0.93, h2) * 0.7); + + float flicker = 0.9 + 0.1 * sin(uTime * (0.35 + h1 * 1.4) + h1 * 42.0); + float level = mix(0.26, 1.0, h2) * flicker; + + vec3 glass = mix(vec3(0.05, 0.075, 0.066), uAccentA * 0.85, 0.22 + vTint * 0.5); + vec3 stone = mix(vec3(0.075, 0.088, 0.08), uAccentC * 0.55, vTint); + vec3 body = mix(stone, glass, 0.58); + + if (vKind > 0.5) body = vec3(0.022, 0.03, 0.027); + if (vKind > 1.5) body *= 0.82; + + // A few blocks are warmer so the skyline is not one flat green. + body = mix(body, vec3(0.11, 0.095, 0.075), smoothstep(0.72, 1.0, vTint) * 0.28); + + vec3 L1 = normalize(vec3(0.78, 0.52, 0.36)); + vec3 L2 = normalize(vec3(-0.6, 0.24, -0.72)); + float d1 = max(dot(n, L1), 0.0); + float d2 = max(dot(n, L2), 0.0); + float ao = mix(0.55, 1.0, smoothstep(0.0, 0.4, up)); + + color = body * (0.4 + d1 * 1.04 + d2 * 0.32) * ao; + color += uAccentA * up * 0.09 * d1; + color += uAccentB * pow(up, 2.4) * 0.025; + color += uAccentC * 0.012 * (1.0 - up); + + color += winCol * win * on * level * (0.42 + 0.46 * up); + color += uAccentB * win * 0.02; + color *= 1.0 - 0.3 * smoothstep(0.9, 0.99, f.y); + + // Lit crown at the top of every tower. + float crown = smoothstep(0.9, 0.995, up); + color += uAccentB * crown * (0.035 + vGlow * 0.08); + + /* Rooftop beacons (antennas, spires). */ + float beacon = vGlow * smoothstep(0.84, 1.0, vLocal.y); + beacon *= 0.55 + 0.45 * sin(uTime * 2.1 + vSeed * 30.0); + color += vec3(1.0, 0.4, 0.3) * beacon * 0.5; + + /* The blamed file glows during the anomaly beat. */ + float hot = clamp(uAnomaly * vFlag, 0.0, 1.0); + float pulse = 0.62 + 0.38 * sin(uTime * 3.1); + color = mix(color, uAnomalyColor * pulse * (0.35 + 0.65 * up), hot * 0.8); + color += uAnomalyColor * hot * fres * 0.22; + } - float rewindBand = smoothstep(0.82, 1.0, sin(vWorld.y * 2.6 - uTime * 7.5)) * uRewind; - base = mix(base, vec3(0.55, 0.92, 1.0), rewindBand * 0.48); + float rewindBand = smoothstep(0.86, 1.0, sin(vWorld.y * 2.2 - uTime * 6.0)) * uRewind; + color = mix(color, vec3(0.58, 0.94, 0.84), rewindBand * 0.32); - vec3 color = base + fresnel * mix(uAccentB, uAccentA, vHealth) * (1.05 + 0.6 * vertical); - color += fresnel * 0.18; + color += uAccentB * fres * 0.07 * vHealth; float fog = 1.0 - exp(-uFogDensity * uFogDensity * vFogDepth * vFogDepth); color = mix(color, uFogColor, clamp(fog, 0.0, 1.0)); - color *= 1.0 - uDim * 0.56; + color *= 1.0 - uDim * 0.42; + + color += (hash21(gl_FragCoord.xy * 0.7) - 0.5) * 0.004; - gl_FragColor = vec4(color, 1.0); + gl_FragColor = vec4(max(color, 0.0), 1.0); } `; @@ -136,13 +239,13 @@ function TowerField({ towers, uniforms, }: { - towers: ReturnType; + towers: Tower[]; uniforms: Uniforms; }) { const meshRef = useRef(null); const geometry = useMemo(() => { - const geo = new THREE.BoxGeometry(1, 1, 1, 1, 1, 1); + const geo = new THREE.BoxGeometry(1, 1, 1); geo.translate(0, 0.5, 0); const count = towers.length; @@ -150,18 +253,30 @@ function TowerField({ const health = new Float32Array(count); const turns = new Float32Array(count); const flags = new Float32Array(count); + const kinds = new Float32Array(count); + const tints = new Float32Array(count); + const lit = new Float32Array(count); + const glow = new Float32Array(count); - towers.forEach((tower: Tower, index: number) => { + towers.forEach((tower, index) => { seeds[index] = tower.seed; health[index] = tower.health; turns[index] = tower.turn; flags[index] = tower.flag; + kinds[index] = tower.kind; + tints[index] = tower.tint; + lit[index] = tower.lit; + glow[index] = tower.glow; }); geo.setAttribute("aSeed", new THREE.InstancedBufferAttribute(seeds, 1)); geo.setAttribute("aHealth", new THREE.InstancedBufferAttribute(health, 1)); geo.setAttribute("aTurn", new THREE.InstancedBufferAttribute(turns, 1)); geo.setAttribute("aFlag", new THREE.InstancedBufferAttribute(flags, 1)); + geo.setAttribute("aKind", new THREE.InstancedBufferAttribute(kinds, 1)); + geo.setAttribute("aTint", new THREE.InstancedBufferAttribute(tints, 1)); + geo.setAttribute("aLit", new THREE.InstancedBufferAttribute(lit, 1)); + geo.setAttribute("aGlow", new THREE.InstancedBufferAttribute(glow, 1)); return geo; }, [towers]); @@ -184,7 +299,7 @@ function TowerField({ const scale = new THREE.Vector3(); towers.forEach((tower, index) => { - position.set(tower.x, 0, tower.z); + position.set(tower.x, tower.base, tower.z); scale.set(tower.width, tower.height, tower.depth); matrix.compose(position, quaternion, scale); mesh.setMatrixAt(index, matrix); @@ -209,6 +324,318 @@ function TowerField({ ); } +/* -------------------------------------------------------------------------- */ +/* Rooftop beacons */ +/* -------------------------------------------------------------------------- */ + +const HALO_VERTEX = /* glsl */ ` + attribute float aSize; + attribute vec3 aColor; + varying vec3 vColor; + + void main() { + vec4 view = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = aSize * (150.0 / max(1.0, -view.z)); + gl_Position = projectionMatrix * view; + vColor = aColor; + } +`; + +const HALO_FRAGMENT = /* glsl */ ` + varying vec3 vColor; + + void main() { + float d = length(gl_PointCoord - vec2(0.5)) * 2.0; + float alpha = pow(max(0.0, 1.0 - d), 3.0) * 0.4; + gl_FragColor = vec4(vColor, alpha); + } +`; + +function TowerHalos({ towers }: { towers: Tower[] }) { + const beacons = useMemo( + () => + towers.filter( + (tower) => tower.kind === 1 && (tower.glow > 0.05 || tower.flag === 1), + ), + [towers], + ); + + const geometry = useMemo(() => { + const positions = new Float32Array(beacons.length * 3); + const sizes = new Float32Array(beacons.length); + const colors = new Float32Array(beacons.length * 3); + const mint = new THREE.Color("#9af0cc"); + const coral = new THREE.Color("#ff8f7d"); + + beacons.forEach((tower, index) => { + positions[index * 3] = tower.x; + positions[index * 3 + 1] = tower.base + tower.height + 0.06; + positions[index * 3 + 2] = tower.z; + sizes[index] = 2.4 + tower.glow * 2.6; + const color = tower.flag === 1 ? coral : mint; + colors[index * 3] = color.r; + colors[index * 3 + 1] = color.g; + colors[index * 3 + 2] = color.b; + }); + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("aSize", new THREE.BufferAttribute(sizes, 1)); + geo.setAttribute("aColor", new THREE.BufferAttribute(colors, 3)); + return geo; + }, [beacons]); + + const material = useMemo( + () => + new THREE.ShaderMaterial({ + vertexShader: HALO_VERTEX, + fragmentShader: HALO_FRAGMENT, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }), + [], + ); + + useEffect(() => { + return () => { + geometry.dispose(); + material.dispose(); + }; + }, [geometry, material]); + + if (!beacons.length) return null; + + return ( + + + + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Light shafts + ground pools */ +/* -------------------------------------------------------------------------- */ + +const BEAM_VERTEX = /* glsl */ ` + attribute float aSeed; + + varying vec2 vUv; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vSeed; + + void main() { + vUv = uv; + vSeed = aSeed; + vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * normal); + vViewDir = normalize(cameraPosition - world.xyz); + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const BEAM_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform vec3 uColor; + uniform float uOpacity; + + varying vec2 vUv; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vSeed; + + void main() { + float core = pow( + clamp(abs(dot(normalize(vNormalW), normalize(vViewDir))), 0.0, 1.0), + 1.8 + ); + float fade = pow(1.0 - clamp(vUv.y, 0.0, 1.0), 1.7); + float pulse = 0.82 + 0.18 * sin(uTime * 0.9 + vSeed * 18.0); + float alpha = core * fade * uOpacity * pulse; + + gl_FragColor = vec4(uColor, alpha); + } +`; + +/** Thin cones of green light rising off the tallest roofs. */ +function LightBeams({ towers, quality }: { towers: Tower[]; quality: Quality }) { + const meshRef = useRef(null); + const beams = useMemo(() => { + const limit = quality === "high" ? 16 : quality === "medium" ? 10 : 6; + return towers + .filter((tower) => tower.kind === 0 && tower.height > 5.2) + .sort((a, b) => b.height - a.height) + .slice(0, limit); + }, [towers, quality]); + + const geometry = useMemo(() => { + const geo = new THREE.CylinderGeometry(0.55, 0.12, 1, 10, 1, true); + geo.translate(0, 0.5, 0); + const seeds = new Float32Array(beams.length); + beams.forEach((tower, index) => { + seeds[index] = tower.seed; + }); + geo.setAttribute("aSeed", new THREE.InstancedBufferAttribute(seeds, 1)); + return geo; + }, [beams]); + + const material = useMemo( + () => + new THREE.ShaderMaterial({ + vertexShader: BEAM_VERTEX, + fragmentShader: BEAM_FRAGMENT, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new THREE.Color("#8fe6c2") }, + uOpacity: { value: 0.26 }, + }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }), + [], + ); + + useLayoutEffect(() => { + const mesh = meshRef.current; + if (!mesh) return; + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + + beams.forEach((tower, index) => { + const height = 4.5 + tower.seed * 8.5; + position.set(tower.x, tower.base + tower.height + 0.04, tower.z); + scale.set(0.7 + tower.seed * 0.5, height, 0.7 + tower.seed * 0.5); + matrix.compose(position, quaternion, scale); + mesh.setMatrixAt(index, matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingSphere(); + }, [beams]); + + useFrame((state) => { + material.uniforms.uTime.value = state.clock.elapsedTime; + }); + + useEffect(() => { + return () => { + geometry.dispose(); + material.dispose(); + }; + }, [geometry, material]); + + if (!beams.length) return null; + + return ( + + ); +} + +const POOL_VERTEX = /* glsl */ ` + varying vec2 vUv; + + void main() { + vUv = uv; + vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const POOL_FRAGMENT = /* glsl */ ` + uniform vec3 uColor; + uniform float uOpacity; + varying vec2 vUv; + + void main() { + float d = length(vUv - 0.5) * 2.0; + float alpha = pow(max(0.0, 1.0 - d), 2.6) * uOpacity; + gl_FragColor = vec4(uColor, alpha); + } +`; + +/** Soft pools of light on the ground around the taller blocks. */ +function BaseGlows({ towers }: { towers: Tower[] }) { + const meshRef = useRef(null); + const glows = useMemo( + () => + towers + .filter((tower) => tower.kind === 0 && tower.height > 4.2) + .slice(0, 28), + [towers], + ); + + const geometry = useMemo(() => { + const geo = new THREE.PlaneGeometry(1, 1); + geo.rotateX(-Math.PI / 2); + return geo; + }, []); + + const material = useMemo( + () => + new THREE.ShaderMaterial({ + vertexShader: POOL_VERTEX, + fragmentShader: POOL_FRAGMENT, + uniforms: { + uColor: { value: new THREE.Color("#7fdcb8") }, + uOpacity: { value: 0.2 }, + }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }), + [], + ); + + useLayoutEffect(() => { + const mesh = meshRef.current; + if (!mesh) return; + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + + glows.forEach((tower, index) => { + position.set(tower.x, 0.035, tower.z); + const spread = 2.1 + tower.seed * 1.6; + scale.set(tower.width * spread, 1, tower.depth * spread); + matrix.compose(position, quaternion, scale); + mesh.setMatrixAt(index, matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingSphere(); + }, [glows]); + + useEffect(() => { + return () => { + geometry.dispose(); + material.dispose(); + }; + }, [geometry, material]); + + if (!glows.length) return null; + + return ( + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Ground */ +/* -------------------------------------------------------------------------- */ + const GROUND_VERTEX = /* glsl */ ` varying vec3 vWorld; @@ -221,32 +648,39 @@ const GROUND_VERTEX = /* glsl */ ` const GROUND_FRAGMENT = /* glsl */ ` uniform float uTime; - uniform vec3 uAccent; + uniform vec3 uAccentA; + uniform vec3 uAccentB; uniform vec3 uFogColor; uniform float uDim; + uniform float uPitch; + uniform float uStreet; + varying vec3 vWorld; void main() { vec2 p = vWorld.xz; - vec2 cell = fract(p * 0.5); - vec2 edge = min(cell, 1.0 - cell); - float line = 1.0 - smoothstep(0.0, 0.035, min(edge.x, edge.y)); + float halfPitch = uPitch * 0.5; + vec2 q = abs(mod(p + halfPitch, uPitch) - halfPitch); + float roadDist = min(q.x, q.y); + float road = 1.0 - smoothstep(uStreet * 0.38, uStreet * 0.54, roadDist); + float curb = smoothstep(uStreet * 0.36, uStreet * 0.5, roadDist) * road; - float distanceToCenter = length(p); - float fade = 1.0 - smoothstep(16.0, 58.0, distanceToCenter); - float radial = exp(-distanceToCenter * 0.075); + float d = length(p); + float fade = 1.0 - smoothstep(18.0, 46.0, d); + float core = exp(-d * 0.115); + float plaza = exp(-d * 0.85); vec3 color = uFogColor; - color += uAccent * line * 0.16 * fade; - color += uAccent * radial * 0.075; + color += uAccentA * core * 0.42; + color += uAccentA * road * 0.09; + color += uAccentB * curb * 0.08; + color += uAccentB * plaza * 0.24; + color += uAccentB * 0.025 * sin(uTime * 0.5 + d * 0.4); - float pool = exp(-length(p - vec2(2.15, 0.35)) * 1.7) * 0.22; - pool += exp(-length(p - vec2(-4.6, 5.4)) * 1.4) * 0.08; - color += uAccent * pool * (0.8 + 0.2 * sin(uTime * 0.7)); + float alpha = (road * 0.18 + curb * 0.26 + core * 0.16 + plaza * 0.32) * fade; + color *= 1.0 - uDim * 0.45; - float alpha = (line * 0.28 + radial * 0.11 + pool * 0.5) * fade; - color *= 1.0 - uDim * 0.5; gl_FragColor = vec4(color, alpha); } `; @@ -268,246 +702,231 @@ function Ground({ uniforms }: { uniforms: Uniforms }) { return ( - + ); } +/* -------------------------------------------------------------------------- */ +/* Ghost */ +/* -------------------------------------------------------------------------- */ + const GHOST_VERTEX = /* glsl */ ` uniform float uTime; + uniform float uAgitation; + varying vec3 vNormalW; varying vec3 vViewDir; varying vec3 vWorld; - varying float vNoise; + varying vec3 vLocal; void main() { vec3 p = position; - float noise = - sin(p.x * 2.2 + uTime * 0.7) * 0.5 + - sin(p.y * 3.1 - uTime * 0.55) * 0.3 + - sin(p.z * 2.7 + uTime * 0.42) * 0.25; + float sway = 1.0 + uAgitation * 2.2; + + float wobble = + (sin(p.y * 3.4 + uTime * 1.5) * 0.013 + + sin(p.x * 4.6 - uTime * 1.1) * 0.009 + + sin(p.z * 4.2 + uTime * 0.9) * 0.009) * sway; - p += normal * noise * 0.11; - vNoise = noise; + float hem = smoothstep(-0.25, -1.0, p.y); + p += normal * (wobble + hem * sin(p.x * 6.0 + uTime * 2.2) * 0.02); + p.x += hem * sin(uTime * 0.9 + p.y * 2.0) * 0.02; vec4 world = modelMatrix * vec4(p, 1.0); + vWorld = world.xyz; + vLocal = position; vNormalW = normalize(mat3(modelMatrix) * normal); vViewDir = normalize(cameraPosition - world.xyz); + gl_Position = projectionMatrix * viewMatrix * world; } `; const GHOST_FRAGMENT = /* glsl */ ` uniform float uTime; - uniform vec3 uAccentA; - uniform vec3 uAccentB; uniform float uOpacity; + uniform vec3 uTint; + uniform vec3 uRim; + varying vec3 vNormalW; varying vec3 vViewDir; varying vec3 vWorld; - varying float vNoise; + varying vec3 vLocal; void main() { - float fresnel = pow( - 1.0 - clamp(abs(dot(normalize(vNormalW), normalize(vViewDir))), 0.0, 1.0), - 1.55 - ); - float scan = 0.5 + 0.5 * sin((vWorld.y + uTime * 0.55) * 6.2); - vec3 color = mix(uAccentA, uAccentB, scan) * (0.55 + fresnel * 0.9); + vec3 n = normalize(vNormalW); + float facing = abs(dot(n, normalize(vViewDir))); + float fres = pow(1.0 - clamp(facing, 0.0, 1.0), 1.9); - float alpha = (0.12 + fresnel * 0.82) * uOpacity; - alpha *= 0.8 + 0.2 * sin(uTime * 1.6 + vWorld.y); - alpha += smoothstep(0.58, 1.0, sin(vWorld.y * 2.8 - uTime * 2.0)) * 0.1 * uOpacity; + float hemFade = smoothstep(-1.06, -0.6, vLocal.y); + float veil = 0.52 + 0.48 * hemFade; + float grain = 0.93 + 0.07 * sin(vLocal.y * 24.0 - uTime * 1.6 + vLocal.x * 7.0); - gl_FragColor = vec4(color, alpha); + vec3 color = mix(uTint, vec3(1.0), 0.35 + fres * 0.65); + color += uRim * fres * 0.1; + + float alpha = uOpacity * (0.15 + fres * 0.4) * veil * grain; + alpha *= mix(0.32, 1.0, hemFade); + + gl_FragColor = vec4(color, clamp(alpha, 0.0, 0.78)); } `; -const TRAIL_COUNT = 30; +const GHOST_HALO_VERTEX = /* glsl */ ` + varying vec2 vUv; -function Ghost({ - ghostPosition, - ghostScale, - uniforms, -}: { - ghostPosition: MutableRefObject; - ghostScale: MutableRefObject; - uniforms: Uniforms; -}) { - const group = useRef(null); - const trailGeometry = useRef(null); - const trailPositions = useMemo( - () => new Float32Array(TRAIL_COUNT * 3), - [], - ); - const initialized = useRef(false); - - const material = useMemo( - () => - new THREE.ShaderMaterial({ - vertexShader: GHOST_VERTEX, - fragmentShader: GHOST_FRAGMENT, - uniforms, - transparent: true, - depthWrite: false, - side: THREE.DoubleSide, - blending: THREE.AdditiveBlending, - }), - [uniforms], - ); - - useEffect(() => () => material.dispose(), [material]); + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; - useFrame((state, delta) => { - const groupNode = group.current; - if (!groupNode) return; - const damp = 1 - Math.exp(-delta * 2.6); - groupNode.position.lerp(ghostPosition.current, damp); - const scale = THREE.MathUtils.lerp( - groupNode.scale.x, - ghostScale.current, - damp, - ); - groupNode.scale.setScalar(scale); - - groupNode.rotation.y = state.clock.elapsedTime * 0.12; - groupNode.rotation.z = Math.sin(state.clock.elapsedTime * 0.3) * 0.05; - - if (trailGeometry.current) { - if (!initialized.current) { - for (let i = 0; i < TRAIL_COUNT; i += 1) { - trailPositions[i * 3] = groupNode.position.x; - trailPositions[i * 3 + 1] = groupNode.position.y; - trailPositions[i * 3 + 2] = groupNode.position.z; - } - initialized.current = true; - } else { - for (let i = TRAIL_COUNT - 1; i > 0; i -= 1) { - trailPositions[i * 3] = trailPositions[(i - 1) * 3]; - trailPositions[i * 3 + 1] = trailPositions[(i - 1) * 3 + 1]; - trailPositions[i * 3 + 2] = trailPositions[(i - 1) * 3 + 2]; - } - trailPositions[0] = groupNode.position.x; - trailPositions[1] = groupNode.position.y; - trailPositions[2] = groupNode.position.z; - } - const attribute = trailGeometry.current.getAttribute( - "position", - ) as THREE.BufferAttribute; - attribute.needsUpdate = true; - } - }); +const GHOST_HALO_FRAGMENT = /* glsl */ ` + uniform vec3 uColor; + uniform float uOpacity; + varying vec2 vUv; - return ( - <> - - - - - - - - - - - - - - - - - - - - - - ); -} + void main() { + float d = length(vUv - 0.5) * 2.0; + float alpha = pow(max(0.0, 1.0 - d), 2.8) * uOpacity; + gl_FragColor = vec4(uColor, alpha); + } +`; -const HALO_VERTEX = /* glsl */ ` +const TRAIL_VERTEX = /* glsl */ ` + attribute float aAlpha; attribute float aSize; - attribute vec3 aColor; - varying vec3 vColor; + uniform float uPixelRatio; + varying float vAlpha; void main() { vec4 view = modelViewMatrix * vec4(position, 1.0); - gl_PointSize = aSize * (150.0 / max(1.0, -view.z)); + gl_PointSize = aSize * uPixelRatio * (90.0 / max(1.0, -view.z)); gl_Position = projectionMatrix * view; - vColor = aColor; + vAlpha = aAlpha; } `; -const HALO_FRAGMENT = /* glsl */ ` - varying vec3 vColor; +const TRAIL_FRAGMENT = /* glsl */ ` + uniform vec3 uColor; + uniform float uOpacity; + varying float vAlpha; void main() { float d = length(gl_PointCoord - vec2(0.5)) * 2.0; - float alpha = pow(max(0.0, 1.0 - d), 2.6) * 0.36; - gl_FragColor = vec4(vColor, alpha); + float alpha = pow(max(0.0, 1.0 - d), 2.2) * vAlpha * uOpacity; + gl_FragColor = vec4(uColor, alpha); } `; -function TowerHalos({ towers }: { towers: ReturnType }) { - const geometry = useMemo(() => { - const positions = new Float32Array(towers.length * 3); - const sizes = new Float32Array(towers.length); - const colors = new Float32Array(towers.length * 3); - const violet = new THREE.Color("#8b7cff"); - const cyan = new THREE.Color("#67e8f9"); - const coral = new THREE.Color("#ff7a6b"); +/** Rounded head, tapered body and a four-lobed wavy hem. */ +function makeGhostGeometry() { + const SEG = 64; + const RINGS = 56; + const headBase = 0.18; + const headRadius = 0.6; + const bottom = -0.98; + + const positions: number[] = []; + const indices: number[] = []; + + for (let i = 0; i <= RINGS; i += 1) { + const v = i / RINGS; + let y: number; + let radius: number; + + if (v < 0.58) { + const t = v / 0.58; + y = bottom + t * (headBase - bottom); + // Matches the head radius at the seam so the silhouette stays smooth. + radius = headRadius - 0.045 * Math.sin(t * Math.PI); + } else { + const t = (v - 0.58) / 0.42; + const angle = t * Math.PI * 0.5; + y = headBase + Math.sin(angle) * headRadius; + radius = headRadius * Math.cos(angle); + } - towers.forEach((tower, index) => { - positions[index * 3] = tower.x; - positions[index * 3 + 1] = tower.height + 0.08; - positions[index * 3 + 2] = tower.z; - sizes[index] = tower.flag ? 4.4 : 2.2 + tower.seed * 1.8; - const color = tower.flag - ? coral - : tower.seed > 0.5 - ? violet - : cyan; - colors[index * 3] = color.r; - colors[index * 3 + 1] = color.g; - colors[index * 3 + 2] = color.b; - }); + for (let j = 0; j <= SEG; j += 1) { + const phi = (j / SEG) * Math.PI * 2; + const hem = Math.pow(Math.max(0, 1 - v / 0.3), 1.35); + const lobe = 0.5 + 0.5 * Math.cos(phi * 5.0); + const yHem = y - hem * (0.025 + 0.05 * lobe); + const rHem = radius * (1 - hem * 0.03); + positions.push(Math.cos(phi) * rHem, yHem, Math.sin(phi) * rHem); + } + } - const geo = new THREE.BufferGeometry(); - geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geo.setAttribute("aSize", new THREE.BufferAttribute(sizes, 1)); - geo.setAttribute("aColor", new THREE.BufferAttribute(colors, 3)); - return geo; - }, [towers]); + for (let i = 0; i < RINGS; i += 1) { + for (let j = 0; j < SEG; j += 1) { + const a = i * (SEG + 1) + j; + const b = a + SEG + 1; + indices.push(a, b, a + 1, b, b + 1, a + 1); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex(indices); + geo.computeVertexNormals(); + return geo; +} + +interface GhostFrame { + x: number; + y: number; + z: number; + scale: number; + opacity: number; + /** 0..1 scroll agitation, feeds the wobble and the trail rate */ + agitation: number; +} + +const TRAIL_MAX = 96; + +function Ghost({ + frame, + uniforms, + quality, +}: { + frame: MutableRefObject; + uniforms: Uniforms; + quality: Quality; +}) { + const group = useRef(null); + const eyes = useRef(null); + const halo = useRef(null); + const target = useMemo(() => new THREE.Vector3(), []); + const geometry = useMemo(() => makeGhostGeometry(), []); + const dpr = useThree((state) => state.viewport.dpr); + const trailCount = + quality === "high" ? TRAIL_MAX : quality === "medium" ? 64 : 36; const material = useMemo( () => new THREE.ShaderMaterial({ - vertexShader: HALO_VERTEX, - fragmentShader: HALO_FRAGMENT, + vertexShader: GHOST_VERTEX, + fragmentShader: GHOST_FRAGMENT, + uniforms, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + }), + [uniforms], + ); + + const haloMaterial = useMemo( + () => + new THREE.ShaderMaterial({ + vertexShader: GHOST_HALO_VERTEX, + fragmentShader: GHOST_HALO_FRAGMENT, + uniforms: { + uColor: { value: new THREE.Color("#d9f7ea") }, + uOpacity: { value: 0.16 }, + }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, @@ -515,28 +934,217 @@ function TowerHalos({ towers }: { towers: ReturnType }) { [], ); + const trail = useMemo(() => { + const positions = new Float32Array(TRAIL_MAX * 3); + const alphas = new Float32Array(TRAIL_MAX); + const sizes = new Float32Array(TRAIL_MAX); + const velocities = new Float32Array(TRAIL_MAX * 3); + const ages = new Float32Array(TRAIL_MAX).fill(99); + const lives = new Float32Array(TRAIL_MAX).fill(1); + + const trailGeometry = new THREE.BufferGeometry(); + const positionAttribute = new THREE.BufferAttribute(positions, 3).setUsage( + THREE.DynamicDrawUsage, + ); + const alphaAttribute = new THREE.BufferAttribute(alphas, 1).setUsage( + THREE.DynamicDrawUsage, + ); + const sizeAttribute = new THREE.BufferAttribute(sizes, 1).setUsage( + THREE.DynamicDrawUsage, + ); + trailGeometry.setAttribute("position", positionAttribute); + trailGeometry.setAttribute("aAlpha", alphaAttribute); + trailGeometry.setAttribute("aSize", sizeAttribute); + + const trailMaterial = new THREE.ShaderMaterial({ + vertexShader: TRAIL_VERTEX, + fragmentShader: TRAIL_FRAGMENT, + uniforms: { + uPixelRatio: { value: 1 }, + uColor: { value: new THREE.Color("#e8fff5") }, + uOpacity: { value: 0.5 }, + }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + return { + geometry: trailGeometry, + material: trailMaterial, + positions, + alphas, + sizes, + velocities, + ages, + lives, + positionAttribute, + alphaAttribute, + sizeAttribute, + head: 0, + emit: 0, + }; + }, []); + + useEffect(() => { + trail.geometry.setDrawRange(0, trailCount); + }, [trail, trailCount]); + useEffect(() => { return () => { geometry.dispose(); material.dispose(); + haloMaterial.dispose(); + trail.geometry.dispose(); + trail.material.dispose(); }; - }, [geometry, material]); + }, [geometry, material, haloMaterial, trail]); + + useFrame((state, delta) => { + const node = group.current; + if (!node) return; + + const t = state.clock.elapsedTime; + const dt = Math.min(delta, 0.05); + const damp = 1 - Math.exp(-dt * 1.9); + const current = frame.current; + + target.set( + current.x + Math.sin(t * 0.42) * 0.34, + current.y + Math.sin(t * 0.78) * 0.2, + current.z + Math.cos(t * 0.36) * 0.3, + ); + + node.position.lerp(target, damp); + node.scale.setScalar( + THREE.MathUtils.lerp(node.scale.x, current.scale, damp), + ); + + // Always face the camera, then add a little weight shift. + node.lookAt(state.camera.position.x, node.position.y, state.camera.position.z); + node.rotateZ(Math.sin(t * 0.55) * 0.05); + node.rotateX(Math.sin(t * 0.4) * 0.03); + + const blinkCycle = t % 4.6; + const blink = + blinkCycle < 0.16 + ? 1 - Math.sin((blinkCycle / 0.16) * Math.PI) + : 1; + if (eyes.current) { + eyes.current.scale.y = Math.max(0.08, blink); + } + + uniforms.uTime.value = t; + uniforms.uAgitation.value = current.agitation; + uniforms.uOpacity.value = THREE.MathUtils.lerp( + uniforms.uOpacity.value as number, + current.opacity, + 1 - Math.exp(-dt * 2.2), + ); + + const opacity = uniforms.uOpacity.value as number; + + if (halo.current) { + halo.current.position.copy(node.position); + halo.current.quaternion.copy(state.camera.quaternion); + const size = node.scale.x * 2.9; + halo.current.scale.set(size, size, 1); + haloMaterial.uniforms.uOpacity.value = opacity * 0.2; + } + + trail.material.uniforms.uPixelRatio.value = dpr; + trail.material.uniforms.uOpacity.value = 0.14 + opacity * 0.4; + + trail.emit += (9 + current.agitation * 44 + current.scale * 3) * dt; + while (trail.emit >= 1) { + trail.emit -= 1; + const index = trail.head % trailCount; + trail.head = (trail.head + 1) % trailCount; + const spread = 0.62 * current.scale; + trail.positions[index * 3] = + node.position.x + (Math.random() - 0.5) * spread; + trail.positions[index * 3 + 1] = + node.position.y - current.scale * 0.62 + Math.random() * 0.24; + trail.positions[index * 3 + 2] = + node.position.z + (Math.random() - 0.5) * spread; + trail.velocities[index * 3] = (Math.random() - 0.5) * 0.18; + trail.velocities[index * 3 + 1] = 0.12 + Math.random() * 0.2; + trail.velocities[index * 3 + 2] = (Math.random() - 0.5) * 0.18; + trail.ages[index] = 0; + trail.lives[index] = 1 + Math.random() * 1.2; + trail.sizes[index] = 0.9 + Math.random() * 1.4; + } + + const drag = Math.pow(0.4, dt); + for (let index = 0; index < trailCount; index += 1) { + const age = trail.ages[index] + dt; + trail.ages[index] = age; + if (age >= trail.lives[index]) { + trail.alphas[index] = 0; + continue; + } + trail.velocities[index * 3] *= drag; + trail.velocities[index * 3 + 1] *= drag; + trail.velocities[index * 3 + 2] *= drag; + trail.positions[index * 3] += trail.velocities[index * 3] * dt; + trail.positions[index * 3 + 1] += trail.velocities[index * 3 + 1] * dt; + trail.positions[index * 3 + 2] += trail.velocities[index * 3 + 2] * dt; + trail.alphas[index] = + Math.pow(1 - age / trail.lives[index], 1.7) * opacity; + } + + trail.positionAttribute.needsUpdate = true; + trail.alphaAttribute.needsUpdate = true; + trail.sizeAttribute.needsUpdate = true; + }); return ( - - - - + <> + + + + + + + + + + + + + + + + + {[-1, 1].map((side) => ( + + + + + + + + + + + ))} + + + ); } +/* -------------------------------------------------------------------------- */ +/* Atmosphere */ +/* -------------------------------------------------------------------------- */ + function Stars({ count }: { count: number }) { const positions = useMemo(() => { const values = new Float32Array(count * 3); for (let i = 0; i < count; i += 1) { - values[i * 3] = (Math.random() - 0.5) * 90; - values[i * 3 + 1] = 13 + Math.random() * 26; - values[i * 3 + 2] = -12 - Math.random() * 48; + values[i * 3] = (Math.random() - 0.5) * 110; + values[i * 3 + 1] = 16 + Math.random() * 30; + values[i * 3 + 2] = -16 - Math.random() * 56; } return values; }, [count]); @@ -547,11 +1155,11 @@ function Stars({ count }: { count: number }) { @@ -564,15 +1172,15 @@ function Dust({ count }: { count: number }) { const positions = useMemo(() => { const values = new Float32Array(count * 3); for (let i = 0; i < count; i += 1) { - values[i * 3] = (Math.random() - 0.5) * 44; - values[i * 3 + 1] = Math.random() * 18 + 0.4; - values[i * 3 + 2] = (Math.random() - 0.5) * 40; + values[i * 3] = (Math.random() - 0.5) * 48; + values[i * 3 + 1] = Math.random() * 16 + 0.4; + values[i * 3 + 2] = (Math.random() - 0.5) * 44; } return values; }, [count]); useFrame((_, delta) => { - if (group.current) group.current.rotation.y += delta * 0.008; + if (group.current) group.current.rotation.y += delta * 0.007; }); return ( @@ -581,11 +1189,11 @@ function Dust({ count }: { count: number }) { @@ -622,6 +1230,10 @@ function PerfProbe({ return null; } +/* -------------------------------------------------------------------------- */ +/* Scene */ +/* -------------------------------------------------------------------------- */ + function CityScene({ signals, quality, @@ -632,14 +1244,25 @@ function CityScene({ reduced: boolean; }) { const { camera, scene } = useThree(); + const cam = camera as THREE.PerspectiveCamera; const towers = useMemo(() => makeCity(quality), [quality]); + const smooth = useRef(0); - const targetLook = useRef(new THREE.Vector3(0, 5, 0)); + const reveal = useRef(7); + const initialized = useRef(false); + const targetLook = useRef(new THREE.Vector3(0, 6, 0)); const desiredPosition = useMemo(() => new THREE.Vector3(), []); const desiredTarget = useMemo(() => new THREE.Vector3(), []); - const ghostPosition = useRef(new THREE.Vector3(4.8, 6.4, -1.5)); - const ghostScale = useRef(1); - const ghostTarget = useMemo(() => new THREE.Vector3(), []); + const ghostFrame = useRef({ + x: 5, + y: 8.6, + z: 4, + scale: 1.7, + opacity: 0.95, + agitation: 0, + }); + const cameraRight = useMemo(() => new THREE.Vector3(), []); + const cameraUp = useMemo(() => new THREE.Vector3(), []); const towerUniforms = useMemo( () => ({ @@ -648,11 +1271,12 @@ function CityScene({ uAnomaly: { value: 0 }, uRewind: { value: 0 }, uDim: { value: 0 }, - uAccentA: { value: new THREE.Color("#8b7cff") }, - uAccentB: { value: new THREE.Color("#67e8f9") }, + uAccentA: { value: new THREE.Color("#1f5f4b") }, + uAccentB: { value: new THREE.Color("#8fe6c2") }, + uAccentC: { value: new THREE.Color("#357f70") }, uAnomalyColor: { value: new THREE.Color("#ff7a6b") }, uFogColor: { value: new THREE.Color(FOG_COLOR) }, - uFogDensity: { value: 0.024 }, + uFogDensity: { value: 0.014 }, }), [], ); @@ -660,9 +1284,12 @@ function CityScene({ const groundUniforms = useMemo( () => ({ uTime: { value: 0 }, - uAccent: { value: new THREE.Color("#8b7cff") }, + uAccentA: { value: new THREE.Color("#1f5f4b") }, + uAccentB: { value: new THREE.Color("#8fe6c2") }, uFogColor: { value: new THREE.Color(FOG_COLOR) }, uDim: { value: 0 }, + uPitch: { value: CITY_LAYOUT.pitch }, + uStreet: { value: CITY_LAYOUT.street }, }), [], ); @@ -670,9 +1297,10 @@ function CityScene({ const ghostUniforms = useMemo( () => ({ uTime: { value: 0 }, - uAccentA: { value: new THREE.Color("#8b7cff") }, - uAccentB: { value: new THREE.Color("#67e8f9") }, - uOpacity: { value: 0.82 }, + uOpacity: { value: 0.95 }, + uAgitation: { value: 0 }, + uTint: { value: new THREE.Color("#eaf8f1") }, + uRim: { value: new THREE.Color("#8fe6c2") }, }), [], ); @@ -681,105 +1309,123 @@ function CityScene({ const dt = Math.min(delta, 0.05); const signal = signals.current; const target = reduced ? 0 : signal.page; - smooth.current += (target - smooth.current) * (1 - Math.exp(-dt * 2.1)); + smooth.current += (target - smooth.current) * (1 - Math.exp(-dt * 2.3)); const key = sampleChoreography(smooth.current); const mouseX = reduced ? 0 : signal.mouseX; const mouseY = reduced ? 0 : signal.mouseY; - const mouseEase = 1 - Math.exp(-dt * 2.4); - - const driftX = Math.sin(state.clock.elapsedTime * 0.12) * 0.42; - const driftY = Math.cos(state.clock.elapsedTime * 0.09) * 0.16; - desiredPosition.set( - key.x + mouseX * 2.6 + driftX, - key.y + mouseY * 1.15 + driftY, - key.z, - ); - desiredTarget.set(key.tx + driftX * 0.2, key.ty + driftY * 0.2, key.tz); - camera.position.lerp(desiredPosition, 1 - Math.exp(-dt * 2.8)); - targetLook.current.lerp(desiredTarget, 1 - Math.exp(-dt * 3.2)); - camera.lookAt(targetLook.current); - const density = key.fog; + const driftX = Math.sin(state.clock.elapsedTime * 0.11) * 0.4; + const driftY = Math.cos(state.clock.elapsedTime * 0.085) * 0.14; + + cameraRight.set(1, 0, 0).applyQuaternion(cam.quaternion); + cameraUp.set(0, 1, 0).applyQuaternion(cam.quaternion); + + desiredPosition.set(key.x + driftX, key.y + driftY, key.z); + desiredPosition.addScaledVector(cameraRight, mouseX * 1.15); + desiredPosition.addScaledVector(cameraUp, mouseY * 0.6); + desiredTarget.set(key.tx, key.ty, key.tz); + desiredTarget.addScaledVector(cameraRight, mouseX * 0.28); + + if (!initialized.current) { + cam.position.copy(desiredPosition); + targetLook.current.copy(desiredTarget); + initialized.current = true; + } else { + cam.position.lerp(desiredPosition, 1 - Math.exp(-dt * 2.6)); + targetLook.current.lerp(desiredTarget, 1 - Math.exp(-dt * 3.0)); + } + + cam.lookAt(targetLook.current); + if (!reduced) cam.rotateZ(key.roll); + + const aspect = state.size.width / Math.max(1, state.size.height); + const aspectBoost = aspect < 0.8 ? 18 : aspect < 1.2 ? 8 : 0; + const nextFov = THREE.MathUtils.damp(cam.fov, key.fov + aspectBoost, 1.8, dt); + if (Math.abs(nextFov - cam.fov) > 0.001) { + cam.fov = nextFov; + cam.updateProjectionMatrix(); + } + if (scene.fog) { - (scene.fog as THREE.FogExp2).density = density; + (scene.fog as THREE.FogExp2).density = key.fog; } const loop = reduced ? 1 : Math.min(1, Math.max(0, signal.loop)); - const reveal = loop <= 0.001 ? 7 : Math.min(7, 1 + loop * 6.4); - const anomalyIn = Math.min( - 1, - Math.max(0, (loop - 0.28) / 0.12), - ); - const anomalyOut = Math.min( - 1, - Math.max(0, (loop - 0.66) / 0.14), - ); + const revealTarget = loop <= 0.001 ? 7 : Math.min(7, 1 + loop * 6.4); + reveal.current += + (revealTarget - reveal.current) * (1 - Math.exp(-dt * 2.2)); + const anomalyIn = Math.min(1, Math.max(0, (loop - 0.28) / 0.12)); + const anomalyOut = Math.min(1, Math.max(0, (loop - 0.66) / 0.14)); const rewindIn = Math.min(1, Math.max(0, (loop - 0.64) / 0.08)); const rewindOut = Math.min(1, Math.max(0, (loop - 0.86) / 0.1)); towerUniforms.uTime.value = reduced ? 3.4 : state.clock.elapsedTime; - towerUniforms.uReveal.value = reveal; + towerUniforms.uReveal.value = reveal.current; towerUniforms.uAnomaly.value = anomalyIn * (1 - anomalyOut); towerUniforms.uRewind.value = rewindIn * (1 - rewindOut); towerUniforms.uDim.value = key.dim; (towerUniforms.uAccentA.value as THREE.Color).copy(key.colorA); (towerUniforms.uAccentB.value as THREE.Color).copy(key.colorB); - towerUniforms.uFogDensity.value = density; + (towerUniforms.uAccentC.value as THREE.Color).copy(key.colorC); + towerUniforms.uFogDensity.value = key.fog; groundUniforms.uTime.value = towerUniforms.uTime.value; groundUniforms.uDim.value = key.dim; - (groundUniforms.uAccent.value as THREE.Color).copy(key.colorA); + (groundUniforms.uAccentA.value as THREE.Color).copy(key.colorA); + (groundUniforms.uAccentB.value as THREE.Color).copy(key.colorB); ghostUniforms.uTime.value = towerUniforms.uTime.value; - ghostUniforms.uOpacity.value = key.ghostOpacity; - (ghostUniforms.uAccentA.value as THREE.Color).copy(key.colorA); - (ghostUniforms.uAccentB.value as THREE.Color).copy(key.colorB); - - const velocity = Math.min(1, Math.abs(signal.velocity) / 28); - ghostTarget.set( - key.ghostX + Math.sin(state.clock.elapsedTime * 0.42) * 0.35 + mouseX * 0.45, - key.ghostY + Math.cos(state.clock.elapsedTime * 0.36) * 0.18 + mouseY * 0.3, - key.ghostZ, + (ghostUniforms.uRim.value as THREE.Color).copy(key.colorB); + + const velocity = Math.min(1, Math.abs(signal.velocity) / 32); + const ghostX = key.ghostX + mouseX * 0.45; + const ghostY = key.ghostY + mouseY * 0.3; + const ghostZ = key.ghostZ; + const ghostDistance = Math.hypot( + ghostX - cam.position.x, + ghostY - cam.position.y, + ghostZ - cam.position.z, ); - ghostPosition.current.lerp(ghostTarget, mouseEase); - ghostScale.current = key.ghostScale * (1 + velocity * 0.12); + + ghostFrame.current.x = ghostX; + ghostFrame.current.y = ghostY; + ghostFrame.current.z = ghostZ; + ghostFrame.current.scale = + THREE.MathUtils.clamp(key.ghostScale * (ghostDistance / 17), 0.75, 1.45) * + (1 + velocity * 0.08); + ghostFrame.current.opacity = key.ghostOpacity; + ghostFrame.current.agitation = reduced ? 0 : velocity; }); return ( <> - - - + + + - + + - - + + + ); } @@ -796,7 +1442,7 @@ export default function GhostCanvas({ { +interface PreparedKey extends Omit { colorA: THREE.Color; colorB: THREE.Color; + colorC: THREE.Color; } const PREPARED: PreparedKey[] = CAMERA_KEYS.map((key) => ({ ...key, colorA: new THREE.Color(key.accentA), colorB: new THREE.Color(key.accentB), + colorC: new THREE.Color(key.accentC), })); export interface ChoreographySample { @@ -152,6 +246,8 @@ export interface ChoreographySample { tz: number; fog: number; dim: number; + fov: number; + roll: number; ghostX: number; ghostY: number; ghostZ: number; @@ -159,24 +255,28 @@ export interface ChoreographySample { ghostOpacity: number; colorA: THREE.Color; colorB: THREE.Color; + colorC: THREE.Color; } const scratch: ChoreographySample = { x: 0, - y: 8, - z: 24, + y: 7, + z: 22.6, tx: 0, - ty: 5, + ty: 6, tz: 0, - fog: 0.024, + fog: 0.014, dim: 0, - ghostX: 4, - ghostY: 6, - ghostZ: 0, - ghostScale: 1, - ghostOpacity: 1, - colorA: new THREE.Color("#8b7cff"), - colorB: new THREE.Color("#67e8f9"), + fov: 42, + roll: 0, + ghostX: 5, + ghostY: 8.6, + ghostZ: 4, + ghostScale: 1.7, + ghostOpacity: 0.85, + colorA: new THREE.Color("#1f5f4b"), + colorB: new THREE.Color("#8fe6c2"), + colorC: new THREE.Color("#357f70"), }; function smootherstep(t: number) { @@ -184,6 +284,18 @@ function smootherstep(t: number) { return x * x * x * (x * (x * 6 - 15) + 10); } +function catmull(p0: number, p1: number, p2: number, p3: number, t: number) { + const t2 = t * t; + const t3 = t2 * t; + return ( + 0.5 * + (2 * p1 + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3) + ); +} + /** Interpolate the film at a given progress. Writes into one scratch object. */ export function sampleChoreography(progress: number): ChoreographySample { const p = Math.min(1, Math.max(0, progress)); @@ -193,28 +305,45 @@ export function sampleChoreography(progress: number): ChoreographySample { index += 1; } - const a = PREPARED[index]; - const b = PREPARED[index + 1] ?? PREPARED[PREPARED.length - 1]; - const span = Math.max(0.0001, b.at - a.at); - const raw = (p - a.at) / span; - const t = smootherstep(raw); - - const lerp = (from: number, to: number) => from + (to - from) * t; - - scratch.x = lerp(a.position[0], b.position[0]); - scratch.y = lerp(a.position[1], b.position[1]); - scratch.z = lerp(a.position[2], b.position[2]); - scratch.tx = lerp(a.target[0], b.target[0]); - scratch.ty = lerp(a.target[1], b.target[1]); - scratch.tz = lerp(a.target[2], b.target[2]); - scratch.fog = lerp(a.fog, b.fog); - scratch.dim = lerp(a.dim, b.dim); - scratch.ghostX = lerp(a.ghost[0], b.ghost[0]); - scratch.ghostY = lerp(a.ghost[1], b.ghost[1]); - scratch.ghostZ = lerp(a.ghost[2], b.ghost[2]); - scratch.ghostScale = lerp(a.ghostScale, b.ghostScale); - scratch.ghostOpacity = lerp(a.ghostOpacity, b.ghostOpacity); - scratch.colorA.copy(a.colorA).lerp(b.colorA, t); - scratch.colorB.copy(a.colorB).lerp(b.colorB, t); + const k0 = PREPARED[Math.max(0, index - 1)]; + const k1 = PREPARED[index]; + const k2 = PREPARED[Math.min(PREPARED.length - 1, index + 1)]; + const k3 = PREPARED[Math.min(PREPARED.length - 1, index + 2)]; + + const span = Math.max(0.0001, k2.at - k1.at); + const t = Math.min(1, Math.max(0, (p - k1.at) / span)); + const ease = smootherstep(t); + + const spline = (pick: (key: PreparedKey) => number) => + catmull(pick(k0), pick(k1), pick(k2), pick(k3), t); + const blend = (from: number, to: number) => from + (to - from) * ease; + + const angle = spline((key) => key.angle); + const radius = Math.max(12, spline((key) => key.radius)); + + scratch.x = Math.sin(angle) * radius; + scratch.z = Math.cos(angle) * radius; + scratch.y = spline((key) => key.height); + scratch.ty = spline((key) => key.targetY); + scratch.tx = Math.sin(angle * 0.7 + 0.8) * 1.1; + scratch.tz = Math.cos(angle * 0.55 + 0.3) * 1.4; + + scratch.fog = spline((key) => key.fog); + scratch.dim = spline((key) => key.dim); + scratch.fov = spline((key) => key.fov); + scratch.roll = spline((key) => key.roll); + + const ghostAngle = spline((key) => key.ghostAngle); + const ghostRadius = spline((key) => key.ghostRadius); + scratch.ghostX = Math.sin(ghostAngle) * ghostRadius; + scratch.ghostZ = Math.cos(ghostAngle) * ghostRadius; + scratch.ghostY = spline((key) => key.ghostHeight); + scratch.ghostScale = blend(k1.ghostScale, k2.ghostScale); + scratch.ghostOpacity = blend(k1.ghostOpacity, k2.ghostOpacity); + + scratch.colorA.copy(k1.colorA).lerp(k2.colorA, ease); + scratch.colorB.copy(k1.colorB).lerp(k2.colorB, ease); + scratch.colorC.copy(k1.colorC).lerp(k2.colorC, ease); + return scratch; } diff --git a/src/components/site/cityData.ts b/src/components/site/cityData.ts index efc59b6..2366344 100644 --- a/src/components/site/cityData.ts +++ b/src/components/site/cityData.ts @@ -3,6 +3,8 @@ import type { Quality } from "./cityChoreography"; export interface Tower { x: number; z: number; + /** vertical offset of the box base (0 = street level) */ + base: number; width: number; depth: number; height: number; @@ -13,8 +15,24 @@ export interface Tower { /** 1 for the tower blamed by the autopsy */ flag: number; seed: number; + /** 0 = building body, 1 = antenna/spire, 2 = podium */ + kind: number; + /** 0..1 position in the accent palette */ + tint: number; + /** 0..1 share of windows that are lit */ + lit: number; + /** rooftop beacon intensity */ + glow: number; } +/** Shared with the ground shader so streets line up with the blocks. */ +export const CITY_LAYOUT = { + blocks: 4, + pitch: 8.2, + street: 2.8, + plaza: 4.2, +}; + function mulberry32(seed: number) { let state = seed >>> 0; return () => { @@ -26,89 +44,182 @@ function mulberry32(seed: number) { }; } -const COUNTS: Record = { - high: { cols: 13, rows: 11, spacing: 2.35, skip: 0.08 }, - medium: { cols: 11, rows: 9, spacing: 2.55, skip: 0.12 }, - low: { cols: 9, rows: 7, spacing: 2.8, skip: 0.16 }, +const SKIP: Record = { + high: 0.04, + medium: 0.16, + low: 0.3, }; /** - * Towers are laid out on a jittered grid. Height encodes file churn; turn - * encodes when the file appeared; the flagged tower is the leak introduced by - * turn 4 in src/store/invoices.ts. + * A block grid with streets instead of a random scatter: every block holds up + * to four lots, tall towers get a setback and an antenna, and the centre is + * kept open as a plaza for the blamed file. */ export function makeCity(quality: Quality): Tower[] { - const config = COUNTS[quality]; const random = mulberry32(0x6c6f7374); const towers: Tower[] = []; + const { blocks, pitch, street, plaza } = CITY_LAYOUT; + const half = (blocks - 1) / 2; + const lot = (pitch - street) / 2; + const skip = SKIP[quality]; + const detailed = quality !== "low"; + + for (let bx = 0; bx < blocks; bx += 1) { + for (let bz = 0; bz < blocks; bz += 1) { + const blockX = (bx - half) * pitch; + const blockZ = (bz - half) * pitch; + const downtown = + 1 - Math.min(1, Math.hypot(blockX, blockZ) / (half * pitch + 1.5)); + const blockTint = random(); + + for (let lx = 0; lx < 2; lx += 1) { + for (let lz = 0; lz < 2; lz += 1) { + if (random() < skip) continue; - for (let row = 0; row < config.rows; row += 1) { - for (let col = 0; col < config.cols; col += 1) { - if (random() < config.skip) continue; + const x = blockX + (lx - 0.5) * lot + (random() - 0.5) * 0.26; + const z = blockZ + (lz - 0.5) * lot + (random() - 0.5) * 0.26; + if (Math.hypot(x, z) < plaza) continue; - const x = - (col - (config.cols - 1) / 2) * config.spacing + - (random() - 0.5) * 0.9; - const z = - (row - (config.rows - 1) / 2) * config.spacing * 0.92 + - (random() - 0.5) * 0.9 - - 1.5; + const width = 1.25 + random() * 0.85; + const depth = 1.25 + random() * 0.85; + const cap = 2.1 + downtown * 5.9; + const height = 1.15 + Math.pow(random(), 1.7) * cap; + const seed = random(); + const tint = Math.min(1, Math.max(0, blockTint * 0.62 + random() * 0.38)); + const lit = 0.22 + random() * 0.58; + const health = 0.55 + random() * 0.45; + const turn = 1 + Math.floor(random() * 7); - if (Math.abs(x) < 1.15 && Math.abs(z) < 1.15) continue; + towers.push({ + x, + z, + base: 0, + width, + depth, + height, + turn, + health, + flag: 0, + seed, + kind: 0, + tint, + lit, + glow: 0, + }); - const height = 0.9 + Math.pow(random(), 2.1) * 7.4; - const width = 0.52 + random() * 0.62; - const depth = 0.52 + random() * 0.62; - const turn = 1 + Math.floor(random() * 7); - const health = 0.55 + random() * 0.45; + // Setback: a slimmer volume stacked on the roof. + if (detailed && height > 4.3 && random() < 0.55) { + towers.push({ + x, + z, + base: height - 0.12, + width: width * 0.62, + depth: depth * 0.62, + height: 0.7 + random() * 2.1, + turn, + health, + flag: 0, + seed: seed + 0.13, + kind: 0, + tint: Math.min(1, tint + 0.12), + lit: lit * 0.85, + glow: 0, + }); + } - towers.push({ - x, - z, - width, - depth, - height, - turn, - health, - flag: 0, - seed: random(), - }); + // Antenna with a small beacon on top. + if (detailed && height > 6.1 && random() < 0.5) { + towers.push({ + x, + z, + base: height, + width: 0.1, + depth: 0.1, + height: 1.1 + random() * 1.9, + turn, + health, + flag: 0, + seed: seed + 0.31, + kind: 1, + tint, + lit: 0, + glow: 0.6 + random() * 0.4, + }); + } + + // Podium / awning at street level. + if (height > 5.0 && random() < 0.28) { + towers.push({ + x, + z, + base: 0, + width: width * 1.38, + depth: depth * 1.38, + height: 0.5, + turn, + health, + flag: 0, + seed: seed + 0.47, + kind: 2, + tint: Math.min(1, tint + 0.2), + lit: 0.3, + glow: 0, + }); + } + } + } } } - // The blamed file: tall, central, and clearly turn 4. + // The blamed file: tall, central, clearly turn 4. + const flagSeed = 0.42; towers.push({ - x: 2.15, - z: 0.35, + x: 0.8, + z: -0.6, + base: 0, + width: 1.75, + depth: 1.55, + height: 8.6, + turn: 4, + health: 0.06, + flag: 1, + seed: flagSeed, + kind: 0, + tint: 0.08, + lit: 0.55, + glow: 0, + }); + towers.push({ + x: 0.8, + z: -0.6, + base: 8.5, width: 1.05, depth: 0.95, - height: 8.7, + height: 1.5, turn: 4, - health: 0.08, + health: 0.06, flag: 1, - seed: 0.42, + seed: flagSeed + 0.2, + kind: 0, + tint: 0.02, + lit: 0.4, + glow: 0, }); - - // A short foreground cluster so the city has depth near the camera. - const foreground: Array<[number, number, number]> = [ - [-4.6, 5.4, 1.8], - [-2.4, 6.6, 2.6], - [0.4, 6.2, 1.3], - [3.9, 5.6, 2.1], - [6.4, 4.9, 1.6], - ]; - foreground.forEach(([x, z, height], index) => { - towers.push({ - x, - z, - width: 0.75 + (index % 3) * 0.12, - depth: 0.72, - height, - turn: 1 + (index % 7), - health: 0.82, - flag: 0, - seed: 0.11 + index * 0.17, - }); + towers.push({ + x: 0.8, + z: -0.6, + base: 10.0, + width: 0.12, + depth: 0.12, + height: 1.9, + turn: 4, + health: 0.06, + flag: 1, + seed: flagSeed + 0.4, + kind: 1, + tint: 0, + lit: 0, + glow: 1, }); return towers; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..439f356 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,116 @@ +// Ghost Cloud — auth primitives. +// Web: HMAC-signed session cookie (GitHub OAuth identity). +// CLI: device bearer tokens — only sha256 hashes are ever stored. +import crypto from "node:crypto"; +import type { NextRequest } from "next/server"; + +export const SESSION_COOKIE = "ghost_session"; +export const OAUTH_STATE_COOKIE = "ghost_oauth"; + +type SessionData = { uid: string; login: string; exp: number }; +type StateData = { state: string; next: string }; + +const b64u = (buf: Buffer | string) => + Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const b64uJSON = (v: unknown) => b64u(JSON.stringify(v)); +function fromB64u(s: string): unknown { + return JSON.parse(Buffer.from(s, "base64").toString("utf8")); +} + +function sign(payload: string): string { + return b64u(crypto.createHmac("sha256", sessionSecret()).update(payload).digest()); +} + +function sessionSecret(): string { + const s = process.env.SESSION_SECRET; + if (!s) throw new Error("SESSION_SECRET is not configured"); + return s; +} + +function cookieBase(maxAge: number) { + return { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + maxAge, + secure: process.env.NODE_ENV === "production", + }; +} + +export function newSessionCookie(res: import("next/server").NextResponse, user: { id: string; login: string }) { + const data: SessionData = { uid: user.id, login: user.login, exp: Date.now() + 30 * 24 * 3600_000 }; + const value = `${b64uJSON(data)}.${sign(b64uJSON(data))}`; + res.cookies.set(SESSION_COOKIE, value, cookieBase(30 * 24 * 3600)); +} + +export function clearSessionCookie(res: import("next/server").NextResponse) { + res.cookies.set(SESSION_COOKIE, "", { ...cookieBase(0), maxAge: 0 }); +} + +export function sessionFrom(req: NextRequest): SessionData | null { + const raw = req.cookies.get(SESSION_COOKIE)?.value; + if (!raw) return null; + const dot = raw.lastIndexOf("."); + if (dot < 1) return null; + const payload = raw.slice(0, dot); + const mac = raw.slice(dot + 1); + try { + if (mac !== sign(payload)) return null; + const data = fromB64u(payload) as SessionData; + if (!data?.uid || data.exp < Date.now()) return null; + return data; + } catch { + return null; + } +} + +export function setOAuthState(res: import("next/server").NextResponse, state: string, next: string) { + const data: StateData = { state, next }; + const value = `${b64uJSON(data)}.${sign(b64uJSON(data))}`; + res.cookies.set(OAUTH_STATE_COOKIE, value, cookieBase(600)); +} + +export function readOAuthState(req: NextRequest): StateData | null { + const raw = req.cookies.get(OAUTH_STATE_COOKIE)?.value; + if (!raw) return null; + const dot = raw.lastIndexOf("."); + if (dot < 1) return null; + const payload = raw.slice(0, dot); + const mac = raw.slice(dot + 1); + try { + if (mac !== sign(payload)) return null; + return fromB64u(payload) as StateData; + } catch { + return null; + } +} + +// ── device tokens ──────────────────────────────────────────────────────────── +export function deviceTokenHash(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export function newDeviceToken(): string { + return `ght_${crypto.randomBytes(24).toString("hex")}`; +} + +/** Bearer-token auth for CLI routes. Returns the owning user id, or null. */ +export async function deviceFromRequest(req: NextRequest): Promise<{ userId: string; deviceId: string } | null> { + const header = req.headers.get("authorization") ?? ""; + const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (!token) return null; + const { q } = await import("./db"); + const { rows } = await q( + `SELECT id, user_id FROM devices WHERE token_hash = $1 AND revoked = false LIMIT 1`, + [deviceTokenHash(token)], + ); + if (!rows.length) return null; + await q(`UPDATE devices SET last_seen_at = now() WHERE id = $1`, [rows[0].id]); + return { userId: rows[0].user_id, deviceId: rows[0].id }; +} + +/** Web-cookie auth helper — returns the signed-in user or null. */ +export function userFromRequest(req: NextRequest): { uid: string; login: string } | null { + const s = sessionFrom(req); + return s ? { uid: s.uid, login: s.login } : null; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..a609d32 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,36 @@ +// Ghost Cloud — Neon PostgreSQL access. +// Pooled singleton (survives Next.js route hot-reload in dev). node-postgres +// does not honor `channel_binding` in the DSN, so it is stripped defensively. +import { Pool, type QueryResultRow } from "pg"; + +declare global { + // eslint-disable-next-line no-var + var __ghostPool: Pool | undefined; +} + +function sanitizeUrl(raw: string | undefined): string | undefined { + if (!raw) return raw; + const [base, qs] = raw.split("?"); + if (!qs) return raw; + const keep = qs.split("&").filter((p) => !p.startsWith("channel_binding=")); + return keep.length ? `${base}?${keep.join("&")}` : base; +} + +function createPool(): Pool { + const url = sanitizeUrl(process.env.DATABASE_URL); + if (!url) throw new Error("DATABASE_URL is not configured"); + const needsSsl = /sslmode=require|neon\.tech/.test(url); + return new Pool({ + connectionString: url, + max: 5, + ssl: needsSsl ? { rejectUnauthorized: false } : undefined, + }); +} + +const pool: Pool = globalThis.__ghostPool ?? (globalThis.__ghostPool = createPool()); + +export function q(text: string, params?: unknown[]) { + return pool.query(text, params as unknown[]); +} + +export type UserRow = { id: string; github_id: string; login: string; name: string | null; avatar_url: string | null }; diff --git a/src/lib/origin.ts b/src/lib/origin.ts new file mode 100644 index 0000000..ad72387 --- /dev/null +++ b/src/lib/origin.ts @@ -0,0 +1,8 @@ +import type { NextRequest } from "next/server"; + +/** Derive the public origin (honors Vercel's forwarded headers). */ +export function originFrom(req: NextRequest): string { + const proto = req.headers.get("x-forwarded-proto") ?? "https"; + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? ""; + return `${proto}://${host}`; +} diff --git a/tsconfig.json b/tsconfig.json index f13bc90..6e54ae2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "packages/opencode-plugin"] } From 9bd4f90a508aad7bc3a25a2afc2963cbd9f4ad04 Mon Sep 17 00:00:00 2001 From: arko33 <221298156+arko33@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:24:03 +0700 Subject: [PATCH 2/2] ci: run TestSprite suite on every pull request --- .github/workflows/testsprite.yml | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/testsprite.yml diff --git a/.github/workflows/testsprite.yml b/.github/workflows/testsprite.yml new file mode 100644 index 0000000..52d9d47 --- /dev/null +++ b/.github/workflows/testsprite.yml @@ -0,0 +1,50 @@ +name: TestSprite + +# Every pull request runs the TestSprite backend suite against the live +# deployment target and fails the check when any test does not pass. +# Official pattern: https://docs.testsprite.com/cli/integrations/ci-cd + +on: + pull_request: + +concurrency: + group: testsprite-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + testsprite: + runs-on: ubuntu-latest + env: + TESTSPRITE_API_KEY: ${{ secrets.TESTSPRITE_API_KEY }} + PROJECT_ID: b14ba0a9-90d6-4c20-873e-583c7783a9fc + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install TestSprite CLI + run: npm install -g @testsprite/testsprite-cli + + - name: Preflight + run: testsprite doctor + + - name: Run suite (backend, wave-ordered batch) + run: | + testsprite test run --all --project "$PROJECT_ID" \ + --wait --timeout 1200 --output json \ + --summary-file ./summary.json > result.json + + - name: Failure bundle + if: failure() + run: | + testsprite test failure get "$(jq -r '.run.testId // .testId // empty' result.json 2>/dev/null)" \ + --out ./.testsprite/failure || true + + - name: Upload failure bundle + if: failure() + uses: actions/upload-artifact@v4 + with: + name: testsprite-failure + path: ./.testsprite/failure