Compare commits

...
4 Commits
38 changed files with 1148 additions and 108 deletions
+4
View File
@@ -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
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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({
@@ -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);
@@ -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);
@@ -0,0 +1,14 @@
import { restartInvestigation } from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function post(_request, { params }) {
try {
const snapshot = await restartInvestigation(params.id);
if (!snapshot) return Response.json({ error: "Investigation not found" }, { status: 404 });
return Response.json({ snapshot });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const POST = withAuthenticatedApi(post);
+14
View File
@@ -0,0 +1,14 @@
import { loadInvestigation } from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function get(_request, { params }) {
try {
const snapshot = await loadInvestigation(params.id);
if (!snapshot) return Response.json({ error: "Investigation not found" }, { status: 404 });
return Response.json({ snapshot });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const GET = withAuthenticatedApi(get);
+29
View File
@@ -0,0 +1,29 @@
import {
listInvestigations,
saveInvestigation,
} from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function get() {
try {
return Response.json({ investigations: await listInvestigations() });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
async function post(request) {
try {
const { id, snapshot } = await request.json();
if (!id || !snapshot || typeof snapshot !== "object") {
return Response.json({ error: "An investigation id and snapshot are required" }, { status: 400 });
}
const savedSnapshot = await saveInvestigation(snapshot, id);
return Response.json({ snapshot: savedSnapshot }, { status: 200 });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const GET = withAuthenticatedApi(get);
export const POST = withAuthenticatedApi(post);
+26
View File
@@ -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;
}
+16 -3
View File
@@ -11,10 +11,21 @@ export default function InvestigationPage({ params }) {
const router = useRouter();
const routeId = typeof params?.id === "string" ? params.id : "";
const [existing, setExisting] = useState(null);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (!routeId) return;
setExisting(loadInvestigation(routeId));
let active = true;
setHydrated(false);
if (!routeId) { setHydrated(true); return; }
(async () => {
try {
const snapshot = await loadInvestigation(routeId);
if (active) setExisting(snapshot);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, [routeId]);
return (
@@ -35,7 +46,9 @@ export default function InvestigationPage({ params }) {
evidence-based structured reconstruction. This is a technical vertical
slice not a production system.
</p>
{existing ? (
{!hydrated ? (
<p className="text-sm text-gray-500">Loading investigation</p>
) : existing ? (
<ScenarioForm
investigationId={routeId}
existingSnapshot={existing}
+19 -6
View File
@@ -15,9 +15,20 @@ export default function ReportPage({ params }) {
const generationAttempted = useRef(false);
useEffect(() => {
setExisting(loadInvestigation(routeId));
setHydrated(true);
}, []);
let active = true;
setHydrated(false);
(async () => {
try {
const snapshot = await loadInvestigation(routeId);
if (active) setExisting(snapshot);
} catch {
if (active) setGenerationError(true);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, [routeId]);
// First-generation: create report when none persists (v0.58)
useEffect(() => {
@@ -54,7 +65,8 @@ export default function ReportPage({ params }) {
const rev = existing?.investigationRevision ?? 0;
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
setExisting((p) => {
saveInvestigation({ ...p, investigationReport: reportData });
void saveInvestigation({ ...p, investigationReport: reportData })
.catch((error) => console.error("Investigation report save failed", error));
return { ...p, investigationReport: reportData };
});
} else {
@@ -73,7 +85,7 @@ export default function ReportPage({ params }) {
if (updateLoading) return;
setUpdateLoading(true);
const snap = loadInvestigation(routeId);
const snap = await loadInvestigation(routeId);
const situationGraph = snap?.situationGraph;
const findings = snap?.findings ?? [];
const rev = snap?.investigationRevision ?? 0;
@@ -99,7 +111,8 @@ export default function ReportPage({ params }) {
if (data.success) {
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
setExisting((p) => {
saveInvestigation({ ...p, investigationReport: reportData });
void saveInvestigation({ ...p, investigationReport: reportData })
.catch((error) => console.error("Investigation report save failed", error));
return { ...p, investigationReport: reportData };
});
}
+5 -1
View File
@@ -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 }) {
<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">
<span className="text-sm font-semibold tracking-wide text-teal-700">Confidence Engine</span>
<ThemeToggle />
<div className="flex items-center gap-2">
<ThemeToggle />
<LogoutButton />
</div>
</div>
</header>
{children}
+5
View File
@@ -0,0 +1,5 @@
import LoginForm from "@/components/login-form";
export default function LoginPage() {
return <LoginForm />;
}
+28 -5
View File
@@ -8,10 +8,23 @@ import { useRouter } from "next/navigation";
function Portfolio() {
const router = useRouter();
const [summaries, setSummaries] = React.useState([]);
const [hydrated, setHydrated] = React.useState(false);
const [loadError, setLoadError] = React.useState(null);
const [showRestartConfirm, setShowRestartConfirm] = React.useState(false);
React.useEffect(() => {
setSummaries(listInvestigations());
let active = true;
(async () => {
try {
const investigations = await listInvestigations();
if (active) setSummaries(investigations);
} catch (error) {
if (active) setLoadError(error);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, []);
return (
@@ -96,10 +109,14 @@ function Portfolio() {
Cancel
</button>
<button
onClick={() => {
onClick={async () => {
setShowRestartConfirm(null);
try { restartInvestigation(summary.id); } catch (_) { /* storage must not crash caller */ }
setSummaries(listInvestigations());
try {
await restartInvestigation(summary.id);
setSummaries(await listInvestigations());
} catch (error) {
setLoadError(error);
}
}}
className="rounded-lg border border-red-400 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 transition"
>
@@ -116,7 +133,13 @@ function Portfolio() {
)}
{/* No investigations */}
{summaries.length === 0 && (
{!hydrated && (
<section className="mb-10"><h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">Investigations</h2><p className="text-sm text-gray-500 italic">Loading investigations</p></section>
)}
{hydrated && loadError && (
<section className="mb-10"><h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">Investigations</h2><p className="text-sm text-red-600">Unable to load investigations.</p></section>
)}
{hydrated && !loadError && summaries.length === 0 && (
<section className="mb-10">
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
Investigations
+45
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+30 -12
View File
@@ -6,7 +6,7 @@ import DiagnosticsView from "@/components/diagnostics-view";
import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace";
import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers";
import { loadInvestigation, saveInvestigation, restartInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage";
import { loadInvestigation, saveInvestigation, restartInvestigation } from "@/lib/storage/investigation-storage";
/* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */
const MOCK_ENABLED = process.env.NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS === "true";
@@ -303,6 +303,16 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* ── v2 findings from focused contributions ─────────────── */
const [findings, setFindings] = useState([]);
const [hydrated, setHydrated] = useState(false);
function persist(snapshot) {
void saveInvestigation(snapshot).catch((error) => console.error("Investigation autosave failed", error));
}
function restartPersistedInvestigation() {
void restartInvestigation(investigationId)
.catch((error) => console.error("Investigation restart failed", error));
}
function appendFinding(finding) {
setFindings((prev) => {
@@ -544,8 +554,11 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* Restore persisted session on mount ─────────── */
useEffect(() => {
if (typeof window === "undefined") return;
const saved = investigationId ? loadInvestigation(investigationId) : null;
if (!saved) return;
let active = true;
(async () => {
try {
const saved = investigationId ? await loadInvestigation(investigationId) : null;
if (!active || !saved) return;
const hasGraph = Boolean(saved.situationGraph);
@@ -569,7 +582,12 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
if (hasGraph) {
setStatus("success");
}
}, []);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, [investigationId]);
/* ── Canonical autosave — persist whenever state changes (Phase 2) ── */
@@ -578,9 +596,9 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
// Guard: no valid investigation yet → skip autosave during idle/start flows.
// Also prevents overwriting an existing saved investigation with the initial
// empty state of a fresh ScenarioForm instance (hydration race guard).
if (!result?.situationGraph) return;
if (!hydrated || !result?.situationGraph) return;
void saveInvestigation({
persist({
id: investigationId,
scenario,
situationGraph: result.situationGraph,
@@ -600,7 +618,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
focusedContributions,
findings,
investigationReport,
investigationRevision,
investigationRevision, hydrated,
]);
/* Restore facilitator dismiss preference (Experiment 05) ─── */
@@ -680,7 +698,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
setResult(normalised);
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
setInvestigationRevision(1);
saveInvestigation({ id: investigationId, scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
persist({ id: investigationId, scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
} else {
setStatus("error");
setCurrentUnderstanding(data.summary ?? null);
@@ -767,7 +785,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* ── v0.59a — provenance: meaningful change advances revision ── */
const nextRev = (investigationRevision ?? 0) + 1;
setInvestigationRevision(nextRev);
saveInvestigation({ id: investigationId, scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev });
persist({ id: investigationId, scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev });
} else {
setUpdateStatus("error");
setUpdateError(outcome);
@@ -947,7 +965,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
}}
onRestart={() => {
restartInvestigation(investigationId);
restartPersistedInvestigation();
setInvestigationRevision(0);
setStatus("idle");
setResult(null);
@@ -968,7 +986,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
{/* ── Continue later banner when session was restored ── */}
{status === "success" && result?.updatedAt && (
<ContinueLaterBanner onRestart={() => { restartInvestigation(investigationId); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
<ContinueLaterBanner onRestart={() => { restartPersistedInvestigation(); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
)}
{/* Reset button after successful analysis */}
@@ -976,7 +994,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
<div className="text-center">
<button
onClick={() => {
restartInvestigation(investigationId);
restartPersistedInvestigation();
setInvestigationRevision(0);
setScenario("");
setStatus("idle");
+33 -36
View File
@@ -7,6 +7,24 @@
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 is now server-authoritative via Supabase `confidence_engine.investigations`. Browser persistence flows through authenticated Next.js API. No `confidence_engine` database schema, tables, snapshot ownership fields, or PostgREST configuration were changed in v0.62c (established in v0.62b).
## Database foundation (v0.62c)
- Server-authoritative investigation persistence via Supabase `confidence_engine.investigations` as durable authority; browser persistence flows through authenticated Next.js API (`/api/investigations`).
- `lib/storage/providers/server-http.js` replaces localStorage as the backing provider for `lib/storage/investigation-storage.js`. The storage seam now owns async load/save and per-investigation coalescing autosave (rapid concurrent saves collapse to the latest snapshot).
- Async hydration adapted across Portfolio, Investigation, Report, and ScenarioForm.
- Restart preserved via shared transformation in `lib/storage/restart-investigation.js`; server-backed restart endpoint reuses this same transformation.
- Portfolio-compatible server summary projection (`scenario`, `updatedAt`, `investigationRevision`, `reportExists`, `reportGeneratedFromRevision`).
- Missing-new-investigation 404 maps to `null` at the load boundary in `server-http.js`.
- Live save and Portfolio reload persistence proven by manual evidence on Sep 8.
- Live application-level user isolation proven: second authenticated user sees clean Portfolio; original user regains only their server-backed investigation.
- localStorage is no longer production authority. Legacy localStorage investigations remain physically present but invisible to normal product flow. No dual-write. No automatic legacy import.
- Duplicate investigation GETs observed on development reload; one database row and one Portfolio card confirmed. No data-integrity defect established. No optimisation undertaken.
**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:
@@ -45,37 +63,16 @@ Does the complete investigation process leave real people materially clearer abo
## Repository checkpoint
- **Branch:** `feature/initial-decomposition-v0.61`
- **HEAD:** `5878ce4` — experiment(confidence-engine): add reconstruction-only helper flag
- **Working tree:** clean after this session's commit
- **Branch:** `feature/product-platform-foundation-v0.62`
- **HEAD:** `6dd447e` — feat(confidence-engine): add authenticated investigation persistence
- **Working tree:** dirty with completed v0.62c cutover (server-authoritative persistence, async seam, 404→null correction)
## Initial reconstruction — current status
## Persistence
**Semantically stable enough for current MVP stage.** Exact graph topology is not stable and is not treated as an invariant. Trust-critical meaning must remain stable. Some compression is acceptable when meaning survives downstream. Missing meaning cannot be faithfully recovered downstream. Causal hypotheses must remain visibly provisional.
Current production default: `reconstruct-v0.5` prompt + canonical reconstruction schema + Zod validation via `z.toJSONSchema()`.
The `/api/cases/start` route returns validated initial reconstruction, situation graph, and selected question. Observability seam exposes the exact object used by `buildInitialGraph()` for comparison.
**Frozen:** initial decomposition, prompt refinement, Qwen/Terra comparison — see CURRENT MVP DIRECTION above.
## Focused investigation — current status
Focused deconstruction plumbing fixes are complete:
- Schema mismatch resolved (focused route now supplies its own `focusedDeconstructJsonSchema`)
- Provider envelope no longer leaks into validator (inner `.response` unwrapped correctly)
- All 48 focused-investigation-boundary tests pass on first run
Focused deconstruction receives only:
- `centralStatement`
- `targetLabel`
- `targetDescription`
- `question`
- `answer`
Full SituationGraph / original scenario / previous findings are **not** supplied to that route. This is intentional epistemic separation.
Repeatability: supplier/weekend-shift epistemic separation repeated 3/3 on the fixed case after plumbing fix. Previous pre-fix semantic runs remain invalid (contaminated by provider-envelope misuse + wrong transport schema).
- **Owner:** `lib/storage/providers/server-http.js` (authenticated browser HTTP provider). `lib/storage/investigation-storage.js` owns the application-facing boundary with coalescing autosave and async load/save.
- **Durable authority:** Supabase `confidence_engine.investigations` (RLS-scoped, user-owned).
- **localStorage:** legacy only — physically present but invisible to normal product flow. No dual-write. No automatic import.
- **Restart transformation:** shared in `lib/storage/restart-investigation.js`; used by both browser seam and server persistence layer.
## Canonical experiment apparatus — currently valid
@@ -101,7 +98,7 @@ Three distinct routes:
/investigations/{id}/report → Investigation Report (derived summary)
```
**Portfolio:** investigation collection with actions per card (View report, Continue investigation, Restart). "+ Create new investigation" allocates durable ID via `crypto.randomUUID()` + navigates.
**Portfolio:** investigation collection loaded from server API (`/api/investigations`). Actions per card: View report, Continue investigation, Restart. "+ Create new investigation" allocates durable ID via `crypto.randomUUID()` + navigates.
**Investigation:** `ScenarioForm` + `ReasoningWorkspace`. Handles focused turns, Done/Re-open semantics, Current Understanding synthesis.
@@ -122,12 +119,12 @@ RAW USER EVIDENCE
## Persistence
- **Owner:** `lib/storage/providers/local-storage.js` (`saveInvestigation` / `loadInvestigation`)
- **Key prefix:** `confidence-engine-investigation:<durable-id>`
- **Storage contract:** `lib/storage/investigation-storage.js` (application-facing boundary)
- **Identity:** durable `id` allocated by application, not storage
- **First persistence:** when user produces meaningful state (scenario submitted), not on create-click
- **Restart:** preserves container/id/scenario; clears reasoning/report state
- **Owner:** `lib/storage/providers/server-http.js` (authenticated browser HTTP provider). `lib/storage/investigation-storage.js` owns the application-facing seam with coalescing autosave.
- **Durable authority:** Supabase `confidence_engine.investigations` (RLS-scoped, user-owned).
- **localStorage:** legacy only — physically present but invisible to normal product flow. No dual-write. No automatic import.
- **Identity:** durable `id` allocated by application, not storage.
- **First persistence:** when user produces meaningful state (scenario submitted), not on create-click.
- **Restart:** preserves container/id/scenario; clears reasoning/report state via shared transformation in `lib/storage/restart-investigation.js`.
## MVP boundaries
+8 -4
View File
@@ -34,6 +34,10 @@ 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 `/`. Server-authoritative investigation persistence via authenticated browser HTTP provider; localStorage is legacy only.
**Database contract (v0.62c):** The applied `confidence_engine.investigations` schema sits outside `public`. Its platform metadata is `id`, `user_id`, and timestamps; the CE payload remains an opaque JSONB `snapshot`. Authenticated RLS ownership is `user_id = auth.uid()`, and external PostgREST configuration exposes the schema. Server persistence is now the production authority; localStorage is legacy only. No dual-write. No automatic legacy import.
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
@@ -75,11 +79,11 @@ Three distinct routes, each with clear ownership:
### Persistence and report lifecycle
- Multi-Investigation collection via localStorage (key prefix `confidence-engine-investigation:<durable-id>`). Legacy singleton path retained for backward compatibility (unused by current product).
- `saveInvestigation()` / `loadInvestigation()` are the canonical storage seams.
- `listInvestigations()` returns lightweight summaries for Portfolio rendering.
- **Server-authoritative:** Supabase `confidence_engine.investigations` via authenticated browser HTTP provider (`lib/storage/providers/server-http.js`). localStorage is legacy only — invisible to normal product flow. No dual-write. No automatic legacy import.
- `saveInvestigation()` / `loadInvestigation()` are the canonical storage seams, backed by server-HTTP provider with coalescing autosave in `lib/storage/investigation-storage.js`.
- `listInvestigations()` returns lightweight summaries for Portfolio rendering from the server API.
- Report generation: first visit → one synthesis call + persist; subsequent visits → zero calls, renders persisted snapshot.
- Restart is destructive and confirmation-gated (dialog → explicit second confirmation`clearInvestigation()`).
- Restart is destructive and confirmation-gated (dialog → explicit second confirmation); uses shared transformation in `lib/storage/restart-investigation.js`.
### Reasoning-engine vs UX/product version lineage
+63 -23
View File
@@ -1,43 +1,81 @@
// investigation-storage — application-facing persistence boundary
// Owns the canonical identity contract: snapshot.id is the sole save identity.
// Delegates to the concrete localStorage provider internally.
// Delegates to the authenticated browser HTTP provider internally.
import { loadInvestigation as _load, saveInvestigation as _save, clearInvestigation as _clear, listInvestigations as _list, restartInvestigation as _restart } from "./providers/local-storage.js";
import { loadInvestigation as _load, saveInvestigation as _save, listInvestigations as _list, restartInvestigation as _restart } from "./providers/server-http.js";
const saveStates = new Map();
function observeUnhandledRejection(promise) {
promise.catch(() => {});
return promise;
}
function getSaveState(id) {
if (!saveStates.has(id)) saveStates.set(id, { inFlight: false, pending: null, idleWaiters: [] });
return saveStates.get(id);
}
async function drainSaveState(id, state) {
while (state.pending) {
const pending = state.pending;
state.pending = null;
try {
const saved = await _save(pending.snapshot, id);
pending.waiters.forEach(({ resolve }) => resolve(saved));
} catch (error) {
pending.waiters.forEach(({ reject }) => reject(error));
}
}
state.inFlight = false;
state.idleWaiters.splice(0).forEach((resolve) => resolve());
}
function waitForSaves(id) {
const state = saveStates.get(id);
if (!state?.inFlight && !state?.pending) return Promise.resolve();
return new Promise((resolve) => state.idleWaiters.push(resolve));
}
/**
* Canonical save contract: snapshot.id is the sole identity authority.
* When snapshot carries an id — persist under that id key (identity-aware).
* When snapshot has no id — fall back to legacy singleton compatibility path.
* A durable id is required and is sent to the authenticated server API.
*/
export function saveInvestigation(snapshot, explicitId) {
if (snapshot && typeof snapshot === "object" && snapshot.id != null) {
return _save(snapshot, snapshot.id);
const id = snapshot?.id ?? explicitId;
if (!snapshot || typeof snapshot !== "object" || !id) {
return observeUnhandledRejection(Promise.reject(new Error("Investigation snapshot and id are required")));
}
// Legacy unidentified snapshot — singleton fallback for unmigrated callers
return _save(snapshot, explicitId);
const state = getSaveState(id);
const promise = new Promise((resolve, reject) => {
// A pending entry has not started yet, so replacing it safely coalesces intermediate autosaves.
if (state.pending) {
state.pending.snapshot = snapshot;
state.pending.waiters.push({ resolve, reject });
} else {
state.pending = { snapshot, waiters: [{ resolve, reject }] };
}
});
if (!state.inFlight) {
state.inFlight = true;
void drainSaveState(id, state);
}
return observeUnhandledRejection(promise);
}
/**
* Canonical load contract: select by durable id when supplied;
* fall back to legacy singleton path otherwise.
* Canonical load contract: select by durable id through the authenticated server API.
*/
export function loadInvestigation(id) {
return _load(id != null ? id : undefined);
}
/**
* Canonical clear contract: remove by identity-aware key when supplied;
* fall back to legacy singleton keys otherwise.
*/
export function clearInvestigation(id) {
return _clear(id ?? undefined);
export async function loadInvestigation(id) {
if (!id) return null;
return _load(id);
}
/**
* Lists all durable-ID Investigation records as lightweight summaries.
* Excludes legacy singleton, sessionStorage state, unrelated storage, malformed entries.
* Server persistence is the authority; legacy browser storage is not consulted.
*/
export function listInvestigations() {
export async function listInvestigations() {
return _list();
}
@@ -49,6 +87,8 @@ export function listInvestigations() {
*
* Missing/invalid id → silently no-op (does NOT fall back to legacy singleton).
*/
export function restartInvestigation(id) {
export async function restartInvestigation(id) {
if (!id) return null;
await waitForSaves(id);
return _restart(id);
}
+3 -11
View File
@@ -4,6 +4,8 @@ const CANONICAL_KEY = "confidence-engine-investigation";
const LEGACY_KEY = "confidence-engine-session";
const SCHEMA_VERSION = 1;
import { restartSnapshot } from "../restart-investigation.js";
// Multi-Investigation key prefix (v0.60c)
const INVESTIGATION_PREFIX = "confidence-engine-investigation:";
@@ -196,17 +198,7 @@ export function restartInvestigation(id) {
const record = JSON.parse(raw);
if (!isPlainObject(record)) return;
// Preserve container fields, reset reasoning-state fields
record.situationGraph = null;
record.selectedQuestion = null;
record.summary = null;
record.focusedContributions = [];
record.findings = [];
record.investigationReport = null;
record.investigationRevision = 0;
record.updatedAt = new Date().toISOString();
_persist(storage, key, JSON.stringify(record));
_persist(storage, key, JSON.stringify(restartSnapshot(record)));
} catch (_) { /* storage errors must not crash caller */ }
}
+41
View File
@@ -0,0 +1,41 @@
async function request(path, options) {
const response = await fetch(path, options);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || "Investigation persistence request failed");
}
return body;
}
export async function saveInvestigation(snapshot, id) {
const body = await request("/api/investigations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, snapshot }),
});
return body.snapshot ?? null;
}
export async function loadInvestigation(id) {
const url = `/api/investigations/${encodeURIComponent(id)}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) return null;
const body = await response.json().catch(() => ({}));
throw new Error(body.error || "Investigation persistence request failed");
}
const body = await response.json();
return body.snapshot ?? null;
}
export async function listInvestigations() {
const body = await request("/api/investigations");
return body.investigations ?? [];
}
export async function restartInvestigation(id) {
const body = await request(`/api/investigations/${encodeURIComponent(id)}/restart`, {
method: "POST",
});
return body.snapshot ?? null;
}
+13
View File
@@ -0,0 +1,13 @@
export function restartSnapshot(snapshot, now = new Date().toISOString()) {
return {
...snapshot,
situationGraph: null,
selectedQuestion: null,
summary: null,
focusedContributions: [],
findings: [],
investigationReport: null,
investigationRevision: 0,
updatedAt: now,
};
}
@@ -0,0 +1,76 @@
import {
createServerSupabaseClient,
getAuthenticatedUser,
} from "@/lib/supabase/server.js";
import { restartSnapshot } from "./restart-investigation.js";
const SCHEMA = "confidence_engine";
const TABLE = "investigations";
async function getAuthenticatedPersistenceContext() {
const user = await getAuthenticatedUser();
if (!user) return null;
return { user, supabase: createServerSupabaseClient() };
}
function throwIfDatabaseError(error) {
if (error) throw new Error("Investigation persistence request failed");
}
export async function saveInvestigation(snapshot, id = snapshot?.id) {
if (!snapshot || typeof snapshot !== "object" || !id) {
throw new Error("Investigation snapshot and id are required");
}
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.upsert({ id, user_id: context.user.id, snapshot }, { onConflict: "id" })
.select("id, snapshot, created_at, updated_at")
.single();
throwIfDatabaseError(error);
return data?.snapshot ?? null;
}
export async function loadInvestigation(id) {
if (!id) return null;
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.select("snapshot")
.eq("id", id)
.maybeSingle();
throwIfDatabaseError(error);
return data?.snapshot ?? null;
}
export async function listInvestigations() {
const context = await getAuthenticatedPersistenceContext();
if (!context) return [];
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.select("id, snapshot, created_at, updated_at")
.order("updated_at", { ascending: false });
throwIfDatabaseError(error);
return (data ?? []).map((record) => ({
id: record.id,
scenario: record.snapshot?.scenario ?? null,
updatedAt: record.snapshot?.updatedAt ?? record.updated_at,
investigationRevision: record.snapshot?.investigationRevision ?? 0,
reportExists: !!record.snapshot?.investigationReport,
reportGeneratedFromRevision: record.snapshot?.investigationReport?.generatedFromRevision ?? null,
}));
}
export async function restartInvestigation(id) {
const snapshot = await loadInvestigation(id);
if (!snapshot) return null;
return saveInvestigation(restartSnapshot(snapshot), id);
}
+13
View File
@@ -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);
};
}
+14
View File
@@ -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`;
}
+33
View File
@@ -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;
}
+36
View File
@@ -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).*)"] };
+128
View File
@@ -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",
+2
View File
@@ -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",
@@ -0,0 +1,53 @@
create schema if not exists confidence_engine;
create table confidence_engine.investigations (
id uuid primary key,
user_id uuid not null references auth.users(id) on delete cascade,
snapshot jsonb not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index investigations_user_id_idx
on confidence_engine.investigations (user_id);
create function confidence_engine.set_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger investigations_set_updated_at
before update on confidence_engine.investigations
for each row execute function confidence_engine.set_updated_at();
alter table confidence_engine.investigations enable row level security;
grant usage on schema confidence_engine to authenticated;
grant select, insert, update, delete on confidence_engine.investigations to authenticated;
create policy "Users can select their own investigations"
on confidence_engine.investigations
for select to authenticated
using (user_id = auth.uid());
create policy "Users can insert their own investigations"
on confidence_engine.investigations
for insert to authenticated
with check (user_id = auth.uid());
create policy "Users can update their own investigations"
on confidence_engine.investigations
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());
create policy "Users can delete their own investigations"
on confidence_engine.investigations
for delete to authenticated
using (user_id = auth.uid());
+80
View File
@@ -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");
});
});
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGetAuthenticatedUser = vi.fn();
const mockCreateServerSupabaseClient = vi.fn();
vi.mock("@/lib/supabase/server.js", () => ({
getAuthenticatedUser: () => mockGetAuthenticatedUser(),
createServerSupabaseClient: () => mockCreateServerSupabaseClient(),
}));
function makeClient(result) {
const query = {
from: vi.fn(() => query),
upsert: vi.fn(() => query),
select: vi.fn(() => query),
eq: vi.fn(() => query),
maybeSingle: vi.fn(() => Promise.resolve(result)),
single: vi.fn(() => Promise.resolve(result)),
order: vi.fn(() => Promise.resolve(result)),
};
return { client: { schema: vi.fn(() => query) }, query };
}
describe("server investigation persistence", () => {
beforeEach(() => vi.clearAllMocks());
it("rejects unauthenticated persistence access", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { saveInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(saveInvestigation({ id: "investigation-1" })).resolves.toBeNull();
expect(mockCreateServerSupabaseClient).not.toHaveBeenCalled();
});
it("saves an unchanged snapshot with user identity derived on the server", async () => {
const snapshot = { id: "investigation-1", scenario: "A situation", user_id: "untrusted" };
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { saveInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(saveInvestigation(snapshot)).resolves.toBe(snapshot);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.from).toHaveBeenCalledWith("investigations");
expect(query.upsert).toHaveBeenCalledWith(
{ id: "investigation-1", user_id: "trusted-user", snapshot },
{ onConflict: "id" },
);
});
it("loads the RLS-scoped stored snapshot", async () => {
const snapshot = { id: "investigation-1", findings: [] };
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { loadInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(loadInvestigation("investigation-1")).resolves.toBe(snapshot);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.eq).toHaveBeenCalledWith("id", "investigation-1");
});
it("lists only RLS-visible persistence records", async () => {
const snapshot = {
scenario: "A situation",
updatedAt: "2026-09-08T00:30:00Z",
investigationRevision: 3,
investigationReport: { generatedFromRevision: 2 },
};
const { client, query } = makeClient({
data: [{ id: "investigation-1", snapshot, created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }],
error: null,
});
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { listInvestigations } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(listInvestigations()).resolves.toEqual([{
id: "investigation-1",
scenario: "A situation",
updatedAt: "2026-09-08T00:30:00Z",
investigationRevision: 3,
reportExists: true,
reportGeneratedFromRevision: 2,
}]);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.select).toHaveBeenCalledWith("id, snapshot, created_at, updated_at");
expect(query.order).toHaveBeenCalledWith("updated_at", { ascending: false });
});
it("restarts an owned snapshot through the server path using the established transformation", async () => {
const snapshot = {
id: "investigation-1", scenario: "A situation", situationGraph: {}, selectedQuestion: "Question",
summary: "Summary", focusedContributions: [{}], findings: [{}], investigationReport: {}, investigationRevision: 4,
};
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { restartInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await restartInvestigation("investigation-1");
expect(query.upsert).toHaveBeenCalledWith(expect.objectContaining({
id: "investigation-1", user_id: "trusted-user", snapshot: expect.objectContaining({
id: "investigation-1", scenario: "A situation", situationGraph: null, selectedQuestion: null,
summary: null, focusedContributions: [], findings: [], investigationReport: null, investigationRevision: 0,
}),
}), { onConflict: "id" });
});
});
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const save = vi.fn();
const load = vi.fn();
const list = vi.fn();
const restart = vi.fn();
vi.mock("@/lib/storage/providers/server-http.js", () => ({
saveInvestigation: (...args) => save(...args),
loadInvestigation: (...args) => load(...args),
listInvestigations: (...args) => list(...args),
restartInvestigation: (...args) => restart(...args),
}));
function deferred() {
let resolve;
const promise = new Promise((next) => { resolve = next; });
return { promise, resolve };
}
describe("server-authoritative investigation storage seam", () => {
beforeEach(() => {
vi.clearAllMocks();
save.mockResolvedValue(null);
});
it("uses only the server provider for async load and list", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
load.mockResolvedValue({ id: "inv-1" });
list.mockResolvedValue([{ id: "inv-1" }]);
await expect(storage.loadInvestigation("inv-1")).resolves.toEqual({ id: "inv-1" });
await expect(storage.listInvestigations()).resolves.toEqual([{ id: "inv-1" }]);
expect(load).toHaveBeenCalledWith("inv-1");
expect(list).toHaveBeenCalledTimes(1);
});
it("allows only one in-flight save per investigation and coalesces rapid pending saves to the latest snapshot", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
const first = deferred();
const second = deferred();
save.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const one = storage.saveInvestigation({ id: "inv-1", revision: 1 });
const two = storage.saveInvestigation({ id: "inv-1", revision: 2 });
const three = storage.saveInvestigation({ id: "inv-1", revision: 3 });
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ id: "inv-1", revision: 1 }, "inv-1");
first.resolve({ id: "inv-1", revision: 1 });
await Promise.resolve();
expect(save).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenLastCalledWith({ id: "inv-1", revision: 3 }, "inv-1");
second.resolve({ id: "inv-1", revision: 3 });
await expect(Promise.all([one, two, three])).resolves.toEqual([
{ id: "inv-1", revision: 1 },
{ id: "inv-1", revision: 3 },
{ id: "inv-1", revision: 3 },
]);
});
it("does not permit an older request to complete after a newer request becomes durable", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
const first = deferred();
const second = deferred();
save.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const older = storage.saveInvestigation({ id: "inv-2", revision: 2 });
const newer = storage.saveInvestigation({ id: "inv-2", revision: 3 });
expect(save).toHaveBeenCalledTimes(1);
first.resolve({ id: "inv-2", revision: 2 });
await Promise.resolve();
expect(save).toHaveBeenLastCalledWith({ id: "inv-2", revision: 3 }, "inv-2");
second.resolve({ id: "inv-2", revision: 3 });
await Promise.all([older, newer]);
expect(save).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as provider from "@/lib/storage/providers/server-http.js";
afterEach(() => vi.unstubAllGlobals());
describe("server HTTP investigation provider", () => {
it("maps save, load, list, and restart to authenticated investigation API paths", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ investigations: [] }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) });
vi.stubGlobal("fetch", fetch);
await expect(provider.saveInvestigation({ id: "inv/a" }, "inv/a")).resolves.toEqual({ id: "inv/a" });
await expect(provider.loadInvestigation("inv/a")).resolves.toEqual({ id: "inv/a" });
await expect(provider.listInvestigations()).resolves.toEqual([]);
await expect(provider.restartInvestigation("inv/a")).resolves.toEqual({ id: "inv/a" });
expect(fetch).toHaveBeenNthCalledWith(1, "/api/investigations", expect.objectContaining({
method: "POST",
body: JSON.stringify({ id: "inv/a", snapshot: { id: "inv/a" } }),
}));
expect(fetch).toHaveBeenNthCalledWith(2, "/api/investigations/inv%2Fa");
expect(fetch).toHaveBeenNthCalledWith(3, "/api/investigations", undefined);
expect(fetch).toHaveBeenNthCalledWith(4, "/api/investigations/inv%2Fa/restart", { method: "POST" });
});
it("returns null for a missing investigation (HTTP 404) rather than throwing", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: "Investigation not found" }) }));
await expect(provider.loadInvestigation("inv/missing")).resolves.toBeNull();
});
it("surfaces API failures rather than returning an empty result", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: "Unauthorized" }) }));
await expect(provider.listInvestigations()).rejects.toThrow("Unauthorized");
});
});