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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/testsprite.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ testsprite_tests/
.vercel/
testsprite_tests/tmp/
testsprite_tests/tmp/config.json

.vercel
.env*
55 changes: 55 additions & 0 deletions app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<UserRow & { name: string | null }>(
`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)}`);
}
29 changes: 29 additions & 0 deletions app/api/auth/github/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions app/api/v1/pair/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
41 changes: 41 additions & 0 deletions app/api/v1/pair/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
148 changes: 148 additions & 0 deletions app/api/v1/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Session API — sync target for the local Ghost recorder.
// POST /api/v1/sessions auth: Bearer <deviceToken> 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<string, unknown> }>;
};

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<ListRow>(
`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();
}
Loading
Loading