diff --git a/.env.example b/.env.example index 2e352ca..501ff9a 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,7 @@ NEXT_PUBLIC_CONFIDENCE_MOCK_DELAY=normal # Scenario to replay: "complete" (jump to end after start) | "error" | "" (default sequential turns) NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCK_SCENARIO=complete + +# Supabase Auth (public browser configuration only) +NEXT_PUBLIC_SUPABASE_URL=https://supabase.rdbcloud.co.uk +NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-supabase-anon-key diff --git a/app/api/analyse/route.js b/app/api/analyse/route.js index fb9563c..31ffb7c 100644 --- a/app/api/analyse/route.js +++ b/app/api/analyse/route.js @@ -3,8 +3,9 @@ import { PROMPT_VERSIONS, DEFAULT_PROMPT_VERSION, } from "@/lib/analysis"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; -export async function POST(request) { +async function post(request) { try { const body = await request.json(); @@ -47,3 +48,5 @@ export async function POST(request) { ); } } + +export const POST = withAuthenticatedApi(post); diff --git a/app/api/cases/overview/route.js b/app/api/cases/overview/route.js index 66750ad..c48cb77 100644 --- a/app/api/cases/overview/route.js +++ b/app/api/cases/overview/route.js @@ -8,8 +8,9 @@ import { getProvider, getProviderModelName } from "@/lib/llm/provider.js"; import { synthesizeInvestigationOverview } from "@/lib/graph/investigation-overview-synthesis.js"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; -export async function POST(request) { +async function post(request) { try { const body = await request.json(); @@ -66,3 +67,5 @@ export async function POST(request) { ); } } + +export const POST = withAuthenticatedApi(post); diff --git a/app/api/cases/start/route.js b/app/api/cases/start/route.js index 69913ba..4ea1ebc 100644 --- a/app/api/cases/start/route.js +++ b/app/api/cases/start/route.js @@ -1,6 +1,7 @@ import { startCase } from "@/lib/graph/orchestrator.js"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; -export async function POST(request) { +async function post(request) { try { const body = await request.json(); const result = await startCase(body); @@ -62,3 +63,5 @@ export async function POST(request) { ); } } + +export const POST = withAuthenticatedApi(post); diff --git a/app/api/cases/synthesis/route.js b/app/api/cases/synthesis/route.js index 9ad9601..672238c 100644 --- a/app/api/cases/synthesis/route.js +++ b/app/api/cases/synthesis/route.js @@ -11,8 +11,9 @@ import { getProvider, getProviderModelName } from "@/lib/llm/provider.js"; import { synthesizeCurrentUnderstanding } from "@/lib/graph/current-understanding-synthesis.js"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; -export async function POST(request) { +async function post(request) { try { const body = await request.json(); @@ -69,3 +70,5 @@ export async function POST(request) { ); } } + +export const POST = withAuthenticatedApi(post); diff --git a/app/api/cases/update/route.js b/app/api/cases/update/route.js index 3eed03f..c0c4477 100644 --- a/app/api/cases/update/route.js +++ b/app/api/cases/update/route.js @@ -2,6 +2,7 @@ import { updateCase, reconsiderCompletedEpisode } from "@/lib/graph/orchestrator import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js"; import { updateCaseEpisodeRequestSchema } from "@/lib/graph/schema.js"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; function mapFailureStatus(result) { switch (result?.stage) { @@ -35,7 +36,7 @@ function buildFailureResponse(result) { }; } -export async function POST(request) { +async function post(request) { try { const body = await request.json(); const isEpisodeMode = body?.episodeMode === true; @@ -89,6 +90,8 @@ export async function POST(request) { } } +export const POST = withAuthenticatedApi(post); + /** Server-side completed-episode reconsideration flow. */ async function handleEpisodeMode(situationGraph, body) { const prepared = prepareCompletedEpisode({ diff --git a/app/api/focused-investigation/deconstruct/route.js b/app/api/focused-investigation/deconstruct/route.js index 2b34ea4..8a948af 100644 --- a/app/api/focused-investigation/deconstruct/route.js +++ b/app/api/focused-investigation/deconstruct/route.js @@ -4,8 +4,9 @@ import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema, } from "@/lib/graph/focused-investigation"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; -export async function POST(request) { +async function post(request) { let targetNodeId = null; let startedAt = null; try { @@ -152,3 +153,5 @@ export async function POST(request) { ); } } + +export const POST = withAuthenticatedApi(post); diff --git a/app/api/focused-investigation/formulate/route.js b/app/api/focused-investigation/formulate/route.js index 8fb8252..521adca 100644 --- a/app/api/focused-investigation/formulate/route.js +++ b/app/api/focused-investigation/formulate/route.js @@ -1,6 +1,7 @@ import { formulateQuestionForTarget } from "@/lib/graph/focused-investigation"; +import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; -export async function POST(request) { +async function post(request) { try { const body = await request.json(); @@ -50,3 +51,5 @@ export async function POST(request) { ); } } + +export const POST = withAuthenticatedApi(post); diff --git a/app/auth/callback/route.js b/app/auth/callback/route.js new file mode 100644 index 0000000..33925fa --- /dev/null +++ b/app/auth/callback/route.js @@ -0,0 +1,26 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse } from "next/server"; + +export async function GET(request) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const response = NextResponse.redirect(new URL("/", requestUrl.origin)); + + if (code) { + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => request.cookies.getAll(), + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options)); + }, + }, + }, + ); + await supabase.auth.exchangeCodeForSession(code); + } + + return response; +} \ No newline at end of file diff --git a/app/layout.jsx b/app/layout.jsx index 7bd221c..ba6fd18 100644 --- a/app/layout.jsx +++ b/app/layout.jsx @@ -1,5 +1,6 @@ import "./globals.css"; import ThemeToggle from "@/components/theme-toggle"; +import LogoutButton from "@/components/logout-button"; export const metadata = { title: "Confidence Engine", @@ -18,7 +19,10 @@ export default function RootLayout({ children }) {
Confidence Engine - +
+ + +
{children} diff --git a/app/login/page.jsx b/app/login/page.jsx new file mode 100644 index 0000000..acec4ef --- /dev/null +++ b/app/login/page.jsx @@ -0,0 +1,5 @@ +import LoginForm from "@/components/login-form"; + +export default function LoginPage() { + return ; +} \ No newline at end of file diff --git a/components/login-form.jsx b/components/login-form.jsx new file mode 100644 index 0000000..ddc94cf --- /dev/null +++ b/components/login-form.jsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { createClient, magicLinkRedirectTo } from "@/lib/supabase/browser.js"; + +export default function LoginForm() { + const [email, setEmail] = useState(""); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(""); + + async function sendMagicLink(event) { + event.preventDefault(); + setStatus("pending"); + setError(""); + const { error: signInError } = await createClient().auth.signInWithOtp({ + email, + options: { emailRedirectTo: magicLinkRedirectTo(window.location.origin) }, + }); + if (signInError) { + setError("We could not send a magic link. Please try again."); + setStatus("idle"); + return; + } + setStatus("sent"); + } + + return ( +
+
+

Welcome

+

Confidence Engine

+

Enter your email and we will send you a secure link to continue.

+
+ + setEmail(event.target.value)} className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-teal-600 focus:outline-none focus:ring-2 focus:ring-teal-400" /> + +
+ {status === "sent" &&

Check your email for your magic link.

} + {error &&

{error}

} +
+
+ ); +} \ No newline at end of file diff --git a/components/logout-button.jsx b/components/logout-button.jsx new file mode 100644 index 0000000..0e2ee01 --- /dev/null +++ b/components/logout-button.jsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase/browser.js"; + +export default function LogoutButton() { + const [visible, setVisible] = useState(false); + const [error, setError] = useState(""); + const [loggingOut, setLoggingOut] = useState(false); + + useEffect(() => { + const client = createClient(); + + async function checkSession() { + const { data: { session } } = await client.auth.getSession(); + setVisible(!!session); + } + + checkSession(); + + const { data: { subscription } } = client.auth.onAuthStateChange((_event, session) => { + setVisible(!!session); + }); + + return () => subscription.unsubscribe(); + }, []); + + async function handleLogout() { + setLoggingOut(true); + setError(""); + try { + const client = createClient(); + await client.auth.signOut(); + window.location.href = "/login"; + } catch (err) { + setError("Could not logout. Try again."); + setLoggingOut(false); + } + } + + if (!visible) return null; + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 9b548a1..e8160a6 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -7,6 +7,11 @@ Initial-decomposition hardening is frozen for the current MVP stage. +## Authenticated product boundary (v0.62a) + +- Confidence Engine uses self-hosted Supabase Auth with magic-link email, `/auth/callback` code exchange, cookie-backed sessions, and protected product routes/API requests; unauthenticated API requests receive 401. +- Investigation persistence remains wholly localStorage-backed and independent of authentication. No `confidence_engine` database schema, tables, snapshot ownership fields, Supabase server configuration, or PostgREST configuration were changed; server persistence remains future work. + **Current product checkpoint:** Read `docs/confidence-engine-product-checkpoint-2026-09-08.md` before planning new product, live-evidence, or commercial work. The core investigation loop is now sufficiently established to prioritise realistic end-to-end use, report experience, prospective-user value, repeat use, and willingness to pay—not endless isolated reasoning-mechanics experiments. Preserve user ownership and address trust-critical defects when found. Do not resume: diff --git a/docs/current-project-state.md b/docs/current-project-state.md index 5e66778..d241713 100644 --- a/docs/current-project-state.md +++ b/docs/current-project-state.md @@ -34,6 +34,8 @@ The product direction is a **facilitated investigation** presented across three **Report:** Renders persisted `investigationReport` snapshot. Generation is on-demand (exactly one `/api/cases/overview` call on first visit; zero on subsequent visits). The Report is a derived artefact, not canonical reasoning evidence. +**Authentication boundary:** Supabase Auth magic links gate product and CE API routes. Sessions are cookie-backed and `/auth/callback` exchanges the auth code before returning to `/`. This does not alter localStorage investigation persistence or introduce user ownership into CE snapshots; dedicated `confidence_engine` PostgreSQL persistence remains future work. + The user controls which question to investigate, how deeply to investigate it, when to say Done for now, whether Current Understanding is sufficient, whether to reopen work, and when to review the Report. The engine facilitates — it does not steer or prioritise. ## September 8, 2026 Product Checkpoint diff --git a/lib/supabase/api-auth.js b/lib/supabase/api-auth.js new file mode 100644 index 0000000..3024650 --- /dev/null +++ b/lib/supabase/api-auth.js @@ -0,0 +1,13 @@ +import { getAuthenticatedUser } from "@/lib/supabase/server.js"; + +export function unauthorizedResponse() { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +export function withAuthenticatedApi(handler) { + return async function authenticatedApiHandler(request, context) { + const user = await getAuthenticatedUser(); + if (!user) return unauthorizedResponse(); + return handler(request, context); + }; +} \ No newline at end of file diff --git a/lib/supabase/browser.js b/lib/supabase/browser.js new file mode 100644 index 0000000..0731985 --- /dev/null +++ b/lib/supabase/browser.js @@ -0,0 +1,14 @@ +"use client"; + +import { createBrowserClient } from "@supabase/ssr"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + ); +} + +export function magicLinkRedirectTo(origin) { + return `${origin}/auth/callback`; +} \ No newline at end of file diff --git a/lib/supabase/server.js b/lib/supabase/server.js new file mode 100644 index 0000000..4dba63e --- /dev/null +++ b/lib/supabase/server.js @@ -0,0 +1,33 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +function getSupabaseConfig() { + return { + url: process.env.NEXT_PUBLIC_SUPABASE_URL, + key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + }; +} + +export function createServerSupabaseClient() { + const cookieStore = cookies(); + const { url, key } = getSupabaseConfig(); + return createServerClient(url, key, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); + } catch { + // Server Components cannot write cookies; middleware refreshes sessions. + } + }, + }, + }); +} + +export async function getAuthenticatedUser() { + const { data: { user } } = await createServerSupabaseClient().auth.getUser(); + return user; +} \ No newline at end of file diff --git a/middleware.js b/middleware.js new file mode 100644 index 0000000..ff37fa1 --- /dev/null +++ b/middleware.js @@ -0,0 +1,36 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse } from "next/server"; + +const PUBLIC_PATHS = ["/login", "/auth"]; + +export async function middleware(request) { + const pathname = request.nextUrl.pathname; + if (PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return NextResponse.next(); + } + + let response = NextResponse.next({ request }); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => request.cookies.getAll(), + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value)); + response = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options)); + }, + }, + }, + ); + const { data: { user } } = await supabase.auth.getUser(); + if (user) return response; + if (pathname.startsWith("/api/")) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const loginUrl = request.nextUrl.clone(); + loginUrl.pathname = "/login"; + loginUrl.searchParams.set("next", pathname); + return NextResponse.redirect(loginUrl); +} + +export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a7e8ae4..ea370de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "confidence-engine", "version": "0.2.0-experimental", "dependencies": { + "@supabase/ssr": "^0.12.7", + "@supabase/supabase-js": "^2.116.0", "next": "^14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -1321,6 +1323,110 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.116.0.tgz", + "integrity": "sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.116.0.tgz", + "integrity": "sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.116.0.tgz", + "integrity": "sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.116.0.tgz", + "integrity": "sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.7.tgz", + "integrity": "sha512-wiBtEie1KkRJi9RrZWY3R2imRhX1JY7qMyUCH2z9AUk15gQebNEplM+urbCKamdxaTJLXUU6LlpkJsaxhojCEg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.114.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.116.0.tgz", + "integrity": "sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.116.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.116.0.tgz", + "integrity": "sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.116.0", + "@supabase/functions-js": "2.116.0", + "@supabase/postgrest-js": "2.116.0", + "@supabase/realtime-js": "2.116.0", + "@supabase/storage-js": "2.116.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -2813,6 +2919,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4296,6 +4415,15 @@ "node": ">= 0.4" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 5fe3f90..6e543dc 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "test:watch": "vitest" }, "dependencies": { + "@supabase/ssr": "^0.12.7", + "@supabase/supabase-js": "^2.116.0", "next": "^14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", diff --git a/tests/auth-boundary.test.js b/tests/auth-boundary.test.js new file mode 100644 index 0000000..9cebe14 --- /dev/null +++ b/tests/auth-boundary.test.js @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockGetAuthenticatedUser = vi.fn(); +const mockStartCase = vi.fn(); +const mockGetUser = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock("@/lib/supabase/server.js", () => ({ + getAuthenticatedUser: () => mockGetAuthenticatedUser(), +})); + +vi.mock("@/lib/graph/orchestrator.js", () => ({ + startCase: (...args) => mockStartCase(...args), +})); + +vi.mock("@/lib/config", () => ({ + getConfig: () => mockGetConfig(), +})); + +vi.mock("@supabase/ssr", () => ({ + createServerClient: () => ({ auth: { getUser: () => mockGetUser() } }), +})); + +describe("authenticated product boundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects an unauthenticated protected API request", async () => { + mockGetAuthenticatedUser.mockResolvedValue(null); + const { withAuthenticatedApi } = await import("@/lib/supabase/api-auth.js"); + const handler = vi.fn(); + + const response = await withAuthenticatedApi(handler)(new Request("http://localhost/api/cases/start")); + + expect(response.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); + + it("allows an authenticated protected API request to reach existing route behavior", async () => { + mockGetAuthenticatedUser.mockResolvedValue({ id: "user-1" }); + mockStartCase.mockResolvedValue({ success: true, updatedSituationGraph: {} }); + const { POST } = await import("@/app/api/cases/start/route.js"); + + const response = await POST(new Request("http://localhost/api/cases/start", { + method: "POST", + body: JSON.stringify({ scenario: "A scenario" }), + })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ success: true }); + expect(mockStartCase).toHaveBeenCalledWith({ scenario: "A scenario" }); + }); + + it("supplies the auth callback as the magic-link redirect target", async () => { + const { magicLinkRedirectTo } = await import("@/lib/supabase/browser.js"); + expect(magicLinkRedirectTo("http://localhost:3000")).toBe("http://localhost:3000/auth/callback"); + }); + + it("keeps infrastructure health public", async () => { + mockGetConfig.mockReturnValue({ ok: false }); + const { GET } = await import("@/app/api/health/route.js"); + + const response = await GET(); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ configPresent: false }); + expect(mockGetAuthenticatedUser).not.toHaveBeenCalled(); + }); + + it("redirects unauthenticated product access to the login surface", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }); + const { middleware } = await import("@/middleware.js"); + const response = await middleware(new NextRequest("http://localhost:3000/")); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("http://localhost:3000/login?next=%2F"); + }); +}); \ No newline at end of file