From 70c55508ac79ff5a80105ab1237ce03554eb4507 Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 7 Sep 2026 09:28:18 -0700 Subject: [PATCH] fix(api): auth-gate /api/setup and /api/seed operator endpoints --- web/__tests__/api/seed-route.test.ts | 125 +++++++++++++++++++++++++++ web/__tests__/api/setup.test.ts | 75 +++++++++++++++- web/app/api/seed/route.ts | 49 ++++++++++- web/app/api/setup/route.ts | 49 ++++++++++- 4 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 web/__tests__/api/seed-route.test.ts diff --git a/web/__tests__/api/seed-route.test.ts b/web/__tests__/api/seed-route.test.ts new file mode 100644 index 00000000..95c3f97a --- /dev/null +++ b/web/__tests__/api/seed-route.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockSql = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/db", () => ({ + sql: () => mockSql, + initializeDatabase: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/chains", () => ({ + getConfiguredSolanaChainContext: () => "solana:devnet", +})); + +import { POST } from "@/app/api/seed/route"; + +function request( + headers: Record = {}, + method: "GET" | "POST" = "POST" +): NextRequest { + return new NextRequest("https://example.com/api/seed", { + method, + headers, + }); +} + +describe("POST /api/seed", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("seeds into an empty database in local development without a secret", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", ""); + // First query: SELECT COUNT(*) -> 0 rows exist. Second: INSERT skills. + // Third: INSERT skill_versions. + mockSql.mockResolvedValueOnce([{ count: "0" }]); + mockSql.mockResolvedValueOnce([ + { id: "11111111-1111-1111-1111-111111111111" }, + ]); + mockSql.mockResolvedValueOnce([]); + + const res = await POST(request()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockSql).toHaveBeenCalledTimes(3); + }); + + it("skips seeding when data already exists", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", ""); + mockSql.mockResolvedValueOnce([{ count: "5" }]); + + const res = await POST(request()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.skipped).toBe(true); + expect(mockSql).toHaveBeenCalledTimes(1); + }); + + it("fails closed in production when CRON_SECRET is unset", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request()); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(body.error).toBe("Unauthorized"); + expect(mockSql).not.toHaveBeenCalled(); + }); + + it("fails closed in preview when CRON_SECRET is unset", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", "preview"); + + const res = await POST(request()); + + expect(res.status).toBe(401); + expect(mockSql).not.toHaveBeenCalled(); + }); + + it("rejects requests without the bearer token when a secret is set", async () => { + vi.stubEnv("CRON_SECRET", "topsecret"); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request()); + + expect(res.status).toBe(401); + expect(mockSql).not.toHaveBeenCalled(); + }); + + it("rejects a wrong bearer token", async () => { + vi.stubEnv("CRON_SECRET", "topsecret"); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request({ authorization: "Bearer wrong" })); + + expect(res.status).toBe(401); + expect(mockSql).not.toHaveBeenCalled(); + }); + + it("accepts a valid bearer token", async () => { + vi.stubEnv("CRON_SECRET", "topsecret"); + vi.stubEnv("VERCEL_ENV", "production"); + mockSql.mockResolvedValueOnce([{ count: "0" }]); + mockSql.mockResolvedValueOnce([ + { id: "11111111-1111-1111-1111-111111111111" }, + ]); + mockSql.mockResolvedValueOnce([]); + + const res = await POST(request({ authorization: "Bearer topsecret" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + }); +}); diff --git a/web/__tests__/api/setup.test.ts b/web/__tests__/api/setup.test.ts index 78f6bf78..dbd03500 100644 --- a/web/__tests__/api/setup.test.ts +++ b/web/__tests__/api/setup.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; const mockBootstrapDatabase = vi.fn(); @@ -8,14 +9,30 @@ vi.mock("@/lib/databaseBootstrap", () => ({ import { POST } from "@/app/api/setup/route"; +function request( + headers: Record = {}, + method: "GET" | "POST" = "POST" +): NextRequest { + return new NextRequest("https://example.com/api/setup", { + method, + headers, + }); +} + describe("POST /api/setup", () => { beforeEach(() => { vi.clearAllMocks(); mockBootstrapDatabase.mockResolvedValue(undefined); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("runs the full database bootstrap", async () => { - const res = await POST(); + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", ""); + const res = await POST(request()); const body = await res.json(); expect(res.status).toBe(200); @@ -24,13 +41,65 @@ describe("POST /api/setup", () => { }); it("returns a 500 when bootstrap fails", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", ""); mockBootstrapDatabase.mockRejectedValue(new Error("boom")); - const res = await POST(); + const res = await POST(request()); const body = await res.json(); expect(res.status).toBe(500); expect(body.success).toBe(false); expect(body.error).toContain("boom"); }); + + it("fails closed in production when CRON_SECRET is unset", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request()); + + expect(res.status).toBe(401); + expect(mockBootstrapDatabase).not.toHaveBeenCalled(); + }); + + it("fails closed in preview when CRON_SECRET is unset", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VERCEL_ENV", "preview"); + + const res = await POST(request()); + + expect(res.status).toBe(401); + expect(mockBootstrapDatabase).not.toHaveBeenCalled(); + }); + + it("rejects requests without the bearer token when a secret is set", async () => { + vi.stubEnv("CRON_SECRET", "topsecret"); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request()); + + expect(res.status).toBe(401); + expect(mockBootstrapDatabase).not.toHaveBeenCalled(); + }); + + it("rejects a wrong bearer token", async () => { + vi.stubEnv("CRON_SECRET", "topsecret"); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request({ authorization: "Bearer wrong" })); + + expect(res.status).toBe(401); + expect(mockBootstrapDatabase).not.toHaveBeenCalled(); + }); + + it("accepts a valid bearer token", async () => { + vi.stubEnv("CRON_SECRET", "topsecret"); + vi.stubEnv("VERCEL_ENV", "production"); + + const res = await POST(request({ authorization: "Bearer topsecret" })); + + expect(res.status).toBe(200); + expect(mockBootstrapDatabase).toHaveBeenCalledOnce(); + }); }); diff --git a/web/app/api/seed/route.ts b/web/app/api/seed/route.ts index f63aff9c..cae7ba40 100644 --- a/web/app/api/seed/route.ts +++ b/web/app/api/seed/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "crypto"; import { sql } from "@/lib/db"; import { getConfiguredSolanaChainContext } from "@/lib/chains"; import { getErrorMessage } from "@/lib/errors"; @@ -6,7 +7,51 @@ import { getErrorMessage } from "@/lib/errors"; type CountRow = { count: string }; type SkillIdRow = { id: string }; -export async function POST() { +// Operator-only endpoint: it writes demo seed rows into the live database. +// Auth matches /api/github/skills/discover: a Bearer CRON_SECRET, compared in +// constant time, that fails closed on any deployed Vercel environment +// (production and preview are both internet-reachable). The endpoint stays +// open in local development so the local dev flow is unchanged. +function timingSafeStringEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) { + return false; + } + return timingSafeEqual(aBuf, bBuf); +} + +function isAuthorized(request: NextRequest): boolean { + const secret = process.env.CRON_SECRET?.trim(); + if (secret) { + return timingSafeStringEqual( + request.headers.get("authorization") ?? "", + `Bearer ${secret}` + ); + } + // No secret configured: fail closed on any deployed Vercel environment. + // Preview deployments are internet-reachable, so "not production" is not a + // safe reason to skip auth. Only allow the open path in local development. + const deployed = + process.env.VERCEL_ENV === "production" || + process.env.VERCEL_ENV === "preview"; + if (deployed) { + console.error( + "[api/seed] CRON_SECRET is not set in a deployed environment; refusing request." + ); + return false; + } + console.warn( + "[api/seed] CRON_SECRET is not set; running without auth (local development only)." + ); + return true; +} + +export async function POST(request: NextRequest) { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const chainContext = getConfiguredSolanaChainContext(); const existingRows = await sql()` diff --git a/web/app/api/setup/route.ts b/web/app/api/setup/route.ts index cb0084f9..cad64e5e 100644 --- a/web/app/api/setup/route.ts +++ b/web/app/api/setup/route.ts @@ -1,8 +1,53 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "crypto"; import { bootstrapDatabase } from "@/lib/databaseBootstrap"; import { getErrorMessage } from "@/lib/errors"; -export async function POST() { +// Operator-only endpoint: it runs schema DDL against the live database. +// Auth matches /api/github/skills/discover: a Bearer CRON_SECRET, compared in +// constant time, that fails closed on any deployed Vercel environment +// (production and preview are both internet-reachable). The endpoint stays +// open in local development so the local dev flow is unchanged. +function timingSafeStringEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + if (aBuf.length !== bBuf.length) { + return false; + } + return timingSafeEqual(aBuf, bBuf); +} + +function isAuthorized(request: NextRequest): boolean { + const secret = process.env.CRON_SECRET?.trim(); + if (secret) { + return timingSafeStringEqual( + request.headers.get("authorization") ?? "", + `Bearer ${secret}` + ); + } + // No secret configured: fail closed on any deployed Vercel environment. + // Preview deployments are internet-reachable, so "not production" is not a + // safe reason to skip auth. Only allow the open path in local development. + const deployed = + process.env.VERCEL_ENV === "production" || + process.env.VERCEL_ENV === "preview"; + if (deployed) { + console.error( + "[api/setup] CRON_SECRET is not set in a deployed environment; refusing request." + ); + return false; + } + console.warn( + "[api/setup] CRON_SECRET is not set; running without auth (local development only)." + ); + return true; +} + +export async function POST(request: NextRequest) { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { await bootstrapDatabase(); return NextResponse.json({