feat(confidence-engine): establish authenticated product boundary
This commit is contained in:
@@ -13,3 +13,7 @@ NEXT_PUBLIC_CONFIDENCE_MOCK_DELAY=normal
|
|||||||
|
|
||||||
# Scenario to replay: "complete" (jump to end after start) | "error" | "" (default sequential turns)
|
# Scenario to replay: "complete" (jump to end after start) | "error" | "" (default sequential turns)
|
||||||
NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCK_SCENARIO=complete
|
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
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {
|
|||||||
PROMPT_VERSIONS,
|
PROMPT_VERSIONS,
|
||||||
DEFAULT_PROMPT_VERSION,
|
DEFAULT_PROMPT_VERSION,
|
||||||
} from "@/lib/analysis";
|
} from "@/lib/analysis";
|
||||||
|
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||||
|
|
||||||
export async function POST(request) {
|
async function post(request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
@@ -47,3 +48,5 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST = withAuthenticatedApi(post);
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
|
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
|
||||||
import { synthesizeInvestigationOverview } from "@/lib/graph/investigation-overview-synthesis.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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
@@ -66,3 +67,5 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST = withAuthenticatedApi(post);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { startCase } from "@/lib/graph/orchestrator.js";
|
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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const result = await startCase(body);
|
const result = await startCase(body);
|
||||||
@@ -62,3 +63,5 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST = withAuthenticatedApi(post);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
|
|
||||||
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
|
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
|
||||||
import { synthesizeCurrentUnderstanding } from "@/lib/graph/current-understanding-synthesis.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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
@@ -69,3 +70,5 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST = withAuthenticatedApi(post);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { updateCase, reconsiderCompletedEpisode } from "@/lib/graph/orchestrator
|
|||||||
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||||
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
|
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
|
||||||
import { updateCaseEpisodeRequestSchema } from "@/lib/graph/schema.js";
|
import { updateCaseEpisodeRequestSchema } from "@/lib/graph/schema.js";
|
||||||
|
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||||
|
|
||||||
function mapFailureStatus(result) {
|
function mapFailureStatus(result) {
|
||||||
switch (result?.stage) {
|
switch (result?.stage) {
|
||||||
@@ -35,7 +36,7 @@ function buildFailureResponse(result) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request) {
|
async function post(request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const isEpisodeMode = body?.episodeMode === true;
|
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. */
|
/** Server-side completed-episode reconsideration flow. */
|
||||||
async function handleEpisodeMode(situationGraph, body) {
|
async function handleEpisodeMode(situationGraph, body) {
|
||||||
const prepared = prepareCompletedEpisode({
|
const prepared = prepareCompletedEpisode({
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import {
|
|||||||
focusedDeconstructJsonSchema,
|
focusedDeconstructJsonSchema,
|
||||||
validateFocusedDeconstructSchema,
|
validateFocusedDeconstructSchema,
|
||||||
} from "@/lib/graph/focused-investigation";
|
} 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 targetNodeId = null;
|
||||||
let startedAt = null;
|
let startedAt = null;
|
||||||
try {
|
try {
|
||||||
@@ -152,3 +153,5 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST = withAuthenticatedApi(post);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { formulateQuestionForTarget } from "@/lib/graph/focused-investigation";
|
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 {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
@@ -50,3 +51,5 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST = withAuthenticatedApi(post);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ThemeToggle from "@/components/theme-toggle";
|
import ThemeToggle from "@/components/theme-toggle";
|
||||||
|
import LogoutButton from "@/components/logout-button";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Confidence Engine",
|
title: "Confidence Engine",
|
||||||
@@ -18,7 +19,10 @@ export default function RootLayout({ children }) {
|
|||||||
<header className="app-chrome border-b border-gray-200/80">
|
<header className="app-chrome border-b border-gray-200/80">
|
||||||
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-6 py-3">
|
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-6 py-3">
|
||||||
<span className="text-sm font-semibold tracking-wide text-teal-700">Confidence Engine</span>
|
<span className="text-sm font-semibold tracking-wide text-teal-700">Confidence Engine</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoginForm from "@/components/login-form";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return <LoginForm />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="mx-auto flex min-h-[calc(100vh-57px)] max-w-[640px] items-center px-6 py-16">
|
||||||
|
<section className="w-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-9 shadow-sm">
|
||||||
|
<p className="mb-3 text-[11px] font-bold uppercase tracking-[.18em] text-teal-700/70">Welcome</p>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Confidence Engine</h1>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-gray-600">Enter your email and we will send you a secure link to continue.</p>
|
||||||
|
<form className="mt-7 space-y-4" onSubmit={sendMagicLink}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700" htmlFor="email">Email address</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(event) => 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" />
|
||||||
|
<button type="submit" disabled={status === "pending"} className="rounded-lg bg-teal-700 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-teal-600 disabled:cursor-wait disabled:opacity-60">
|
||||||
|
{status === "pending" ? "Sending magic link…" : "Send magic link"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status === "sent" && <p className="mt-5 text-sm text-green-700" role="status">Check your email for your magic link.</p>}
|
||||||
|
{error && <p className="mt-5 text-sm text-red-700" role="alert">{error}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={loggingOut}
|
||||||
|
aria-label="Logout"
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loggingOut ? "Logging out…" : "Logout"}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
Initial-decomposition hardening is frozen for the current MVP stage.
|
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.
|
**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:
|
Do not resume:
|
||||||
|
|||||||
@@ -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.
|
**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.
|
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
|
## September 8, 2026 Product Checkpoint
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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).*)"] };
|
||||||
Generated
+128
@@ -8,6 +8,8 @@
|
|||||||
"name": "confidence-engine",
|
"name": "confidence-engine",
|
||||||
"version": "0.2.0-experimental",
|
"version": "0.2.0-experimental",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.12.7",
|
||||||
|
"@supabase/supabase-js": "^2.116.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
@@ -1321,6 +1323,110 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -2813,6 +2919,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4296,6 +4415,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.12.7",
|
||||||
|
"@supabase/supabase-js": "^2.116.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user