Compare commits

..
Author SHA1 Message Date
robbond 2dc1cb0fe6 docs(confidence-engine): record tester readiness findings 2026-09-11 10:34:53 +01:00
robbond 2349ef9d59 docs(confidence-engine): close pre-tester security review 2026-09-10 14:21:10 +01:00
robbond a6796c6f73 fix(confidence-engine): bound focused investigation input 2026-09-10 13:23:39 +01:00
robbond 2cb2d556fd fix(confidence-engine): sanitize reasoning error responses 2026-09-10 11:39:13 +01:00
robbond b9d49ff277 fix(confidence-engine): correct legal footer layout 2026-09-10 06:40:30 +01:00
robbond 27ded883e5 feat(confidence-engine): add tester legal pages 2026-09-10 06:18:28 +01:00
robbond 96b47d16a3 fix(confidence-engine): decouple app health from provider 2026-09-09 20:00:52 +01:00
robbond 00d7593ffd fix(confidence-engine): preserve scenario during outage 2026-09-09 19:45:55 +01:00
robbond 60c90bdd6a fix(confidence-engine): propagate start outage classification 2026-09-09 19:36:29 +01:00
robbond 707fe1b3c0 fix(confidence-engine): sanitize start reasoning outage 2026-09-09 18:02:29 +01:00
robbond ed033e71d5 fix(confidence-engine): sanitize focused reasoning outage 2026-09-09 17:34:56 +01:00
robbond 6974b710de fix(confidence-engine): refine landing page layout 2026-09-09 16:33:06 +01:00
robbond b93aad9667 fix(confidence-engine): simplify responsive login layout 2026-09-09 16:15:55 +01:00
robbond 5f423d2f5f fix(login): correct desktop layout — sections 1+3 form left column, sign-in card right at 420px; mobile order: proposition → sign-in → HOW IT WORKS 2026-09-09 15:21:26 +01:00
robbond 4d78c81773 fix(confidence-engine): prioritise mobile sign-in 2026-09-09 14:20:27 +01:00
robbond efdb0a29d1 docs(confidence-engine): record first-time framing increment 2026-09-09 14:04:36 +01:00
robbond 3bfc1c5c4b feat(confidence-engine): add first-time product framing 2026-09-09 14:04:08 +01:00
robbond fdb6c86ee2 docs(confidence-engine): checkpoint deployed product platform 2026-09-09 13:37:03 +01:00
robbond e18de817b4 build(confidence-engine): ignore deployment environment 2026-09-09 13:30:22 +01:00
robbond 607a2ad98b fix(confidence-engine): use deployment host address in quotes 2026-09-09 13:23:22 +01:00
robbond 3e473dcf55 fix(confidence-engine): use deployment host address 2026-09-09 13:21:47 +01:00
robbond dafb9ae9b7 fix(confidence-engine): bind Jenkins deployment SSH context 2026-09-09 13:19:21 +01:00
robbond b964f2f172 fix(confidence-engine): give Jenkins pipeline node context 2026-09-09 13:02:19 +01:00
robbond 2c535080d2 build(confidence-engine): add manual Jenkins deployment 2026-09-09 12:44:55 +01:00
robbond bb62a65174 fix(confidence-engine): preserve external auth callback origin 2026-09-09 11:26:57 +01:00
robbond 0125975ccb fix(confidence-engine): sanitize public health response 2026-09-09 10:33:54 +01:00
robbond f557175bfb fix(confidence-engine): keep health endpoint public 2026-09-09 10:01:10 +01:00
robbond 896851e68d build(confidence-engine) remove copy line from dockerfile 2026-09-09 09:52:55 +01:00
robbond 2079be690f build(confidence-engine) fix dockerfile env var 2026-09-09 09:47:45 +01:00
robbond e6c78f87fe build(confidence-engine): add production container packaging 2026-09-09 06:35:15 +01:00
robbond d6df1d210e feat(confidence-engine): use server investigation persistence 2026-09-08 19:21:05 +01:00
robbond 6dd447e56a feat(confidence-engine): add authenticated investigation persistence 2026-09-08 17:15:46 +01:00
robbond b949eea831 feat(confidence-engine): define investigation persistence schema 2026-09-08 16:56:07 +01:00
robbond 30bf44f2e5 feat(confidence-engine): establish authenticated product boundary 2026-09-08 16:30:07 +01:00
63 changed files with 3023 additions and 214 deletions
+37
View File
@@ -0,0 +1,37 @@
node_modules
.next
out
dist
coverage
*.lcov
test-results
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
evaluation-results
provider-debug-results
tests-results
.playwright-mcp/
.evidence-temp/
# Git
.git
.gitignore
# Environment files with secrets (never bake into image)
.env
.env.local
.env.*.local
# Documentation / handoff (not needed for build)
docs
*.md
# IDE
.vscode
.idea
# OS generated files
.DS_Store
Thumbs.db
+6 -9
View File
@@ -1,15 +1,12 @@
# Local Ollama server address
OLLAMA_BASE_URL=http://192.168.x.x:11434
# ── 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
# Model name (e.g., llama3, mistral, codellama, etc.)
# ── Ollama provider (runtime, server-only) ────────────────────────────
OLLAMA_BASE_URL=http://192.168.x.x:11434
OLLAMA_MODEL=replace-with-model-name
# ── Mock / Demo Mode (UI development only) ──────────────────
# Set to "true" to use pre-recorded scenario fixtures instead of Ollama.
# ── Mock / Demo Mode (UI development only) ────────────────────────────
NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS=true
# Mock delay mode: "instant" | "normal" (default, 700ms) | "slow" (2500ms)
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
+3
View File
@@ -46,3 +46,6 @@ tests-results/
# Evidence/temp directories from live experiments
.evidence-temp/
# Jenkins env
deploy.env
+45
View File
@@ -0,0 +1,45 @@
# ── Stage 1: Build ─────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN corepack enable && \
if [ -f pnpm-lock.yaml ]; then \
corepack prepare pnpm@latest --activate; \
pnpm install --frozen-lockfile; \
elif [ -f yarn.lock ]; then \
yarn install --frozen-lockfile; \
else \
npm ci; \
fi
COPY . .
RUN npm run build
# ── Stage 2: Production runtime ────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Vendored
+114
View File
@@ -0,0 +1,114 @@
// Confidence Engine — Manual Jenkins Deployment Pipeline
//
// Trigger: manually, via "Build with Parameters"
// Parameter: GIT_REF (string) — the Git ref to deploy
//
// Pre-requisites in Jenkins:
// 1. SSH credential of type "SSH Username with private key"
// named 'confidence-engine-deploy-ssh' that can reach
// CT 112 (confidence-engine / 192.168.68.73).
// The username from the credential is used for SSH login.
// 2. The Gitea repository configured in the job SCM section.
pipeline {
agent any
parameters {
string(
name: 'GIT_REF',
defaultValue: '',
description: 'Git ref to deploy (branch name, tag, or full commit SHA). Leave blank to fail.'
)
}
environment {
TARGET_HOST = '192.168.68.73'
DEPLOY_DIR = '/opt/confidence-engine'
HEALTH_URL = 'http://127.0.0.1:3000/api/health'
}
stages {
stage('Resolve') {
steps {
script {
def ref = params.GIT_REF.trim()
if (!ref || ref.isEmpty()) {
error 'GIT_REF parameter is blank or empty. Provide a Git ref to deploy.'
}
echo "Requested ref: ${ref}"
// Resolve the ref to an exact SHA via Gitea remote.
// If ref is already a 40-char hex SHA, use it directly.
def shaPattern = ~/^[0-9a-fA-F]{40}$/
def resolvedSha
if (ref ==~ shaPattern) {
resolvedSha = ref
echo "Provided ref is a full commit SHA: ${resolvedSha}"
} else {
// For branches/tags, look up on origin
resolvedSha = sh(
script: "git ls-remote origin refs/heads/${ref} refs/tags/${ref} 2>/dev/null | awk '/^[0-9a-f]/{print \$1; exit}'",
returnStdout: true
).trim()
if (!resolvedSha || resolvedSha.length() != 40) {
// Broader fallback — might match partial SHA or ref prefix
resolvedSha = sh(
script: "git ls-remote origin ${ref} 2>/dev/null | awk '/^[0-9a-f]/{print \$1; exit}'",
returnStdout: true
).trim()
if (!resolvedSha || resolvedSha.length() != 40) {
error "Cannot resolve '${ref}' to a commit SHA on origin. Check the ref and repository configuration."
}
}
}
echo "Resolved SHA: ${resolvedSha}"
env.DEPLOY_SHA = resolvedSha
}
}
}
stage('Deploy') {
steps {
script {
// Run the deployment script on CT 112 via SSH
withCredentials([sshUserPrivateKey(
credentialsId: 'confidence-engine-deploy-ssh',
keyFileVariable: 'SSH_KEY',
usernameVariable: 'SSH_USER'
)]) {
sh '''
ssh \
-i "$SSH_KEY" \
-o StrictHostKeyChecking=yes \
"$SSH_USER@$TARGET_HOST" \
bash -s -- "$DEPLOY_SHA" "$DEPLOY_DIR" "$HEALTH_URL" \
< "$WORKSPACE/scripts/deploy-production.sh"
'''
}
}
}
}
stage('Verify/result') {
steps {
script {
echo "Deployment stages completed. Check the Deploy stage output above for success/failure."
}
}
}
}
post {
failure {
echo 'DEPLOYMENT FAILED — check the Deploy stage logs for details.'
}
success {
echo "DEPLOYMENT SUCCEEDED — deployed SHA: ${env.DEPLOY_SHA}"
}
}
}
+23 -4
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();
@@ -25,7 +26,7 @@ export async function POST(request) {
if (!result.success) {
return Response.json(
{ ...result, reconstruction: result.reconstruction || null },
{ error: "Reasoning request could not be completed." },
{ status: Number(result.statusCode) || 500 },
);
}
@@ -41,9 +42,27 @@ export async function POST(request) {
promptVersion: result.promptVersion,
});
} catch (e) {
if (e.code === "PROVIDER_UNAVAILABLE") {
return Response.json(
{ error: "Reasoning service is temporarily unavailable." },
{ status: 503 },
);
}
const validationFailed = e?.validationFailed || e?.code === "VALIDATION_FAILED";
if (validationFailed) {
return Response.json(
{ success: false, error: "Reasoning request could not be completed." },
{ status: Number(e.statusCode) || 500 },
);
}
const statusCode = Number(e.statusCode) || 500;
return Response.json(
{ error: e.message || "Unknown server error", responseDurationMs: 0 },
{ status: 500 },
{ error: "Reasoning request could not be completed." },
{ status: statusCode },
);
}
}
export const POST = withAuthenticatedApi(post);
+7 -2
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();
@@ -54,7 +55,9 @@ export async function POST(request) {
{
success: false,
stage: error.statusCode === 400 ? "request_validation" : "provider",
error: error.message ?? "Overview synthesis failed",
error: error.statusCode === 400
? "Invalid overview request"
: "Reasoning request could not be completed.",
},
{ status: error.statusCode }
);
@@ -66,3 +69,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
+17 -9
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);
@@ -9,6 +10,17 @@ export async function POST(request) {
return Response.json(result, { status: 200 });
}
if (result.code === "PROVIDER_UNAVAILABLE") {
console.error("[api/cases/start] provider unavailable", {
error: result.error ?? "Start case failed",
providerApiPath: result.providerApiPath,
});
return Response.json(
{ success: false, error: "Reasoning service is temporarily unavailable." },
{ status: 503 },
);
}
const status =
result.statusCode === 400
? 400
@@ -36,14 +48,8 @@ export async function POST(request) {
return Response.json(
{
success: false,
error: diagnostics.error,
validationErrors: result.validationErrors,
diagnostics: result.diagnostics,
analysisErrors: result.analysisErrors,
validationIssues: result.validationIssues,
providerApiPath: result.providerApiPath,
providerExecution: result.providerExecution,
rawResponse: result.rawResponse ?? undefined,
error: status === 400 ? diagnostics.error : "Reasoning request could not be completed.",
...(status === 400 ? { validationErrors: result.validationErrors } : {}),
},
{ status },
);
@@ -62,3 +68,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
+7 -2
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();
@@ -56,7 +57,9 @@ export async function POST(request) {
{
success: false,
stage: error.statusCode === 400 ? "request_validation" : "provider",
error: error.message ?? "Synthesis failed",
error: error.statusCode === 400
? "Invalid synthesis request"
: "Reasoning request could not be completed.",
},
{ status: error.statusCode }
);
@@ -69,3 +72,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 {
@@ -49,6 +50,23 @@ export async function POST(request) {
);
}
const REQUEST_LENGTH_LIMITS = {
answer: 10000,
question: 2048,
centralStatement: 2048,
targetLabel: 2048,
targetDescription: 2048,
};
for (const [field, limit] of Object.entries(REQUEST_LENGTH_LIMITS)) {
if (body[field] && body[field].length > limit) {
return Response.json(
{ error: `Request field "${field}" exceeds maximum length of ${limit} characters` },
{ status: 400 },
);
}
}
const prompt = buildFocusedDeconstructPrompt({
targetLabel: body.targetLabel,
targetDescription: body.targetDescription,
@@ -143,12 +161,20 @@ export async function POST(request) {
});
console.info("[api/focused-investigation/deconstruct] end", {
targetNodeId,
status: 500,
status: e?.code === "PROVIDER_UNAVAILABLE" ? 503 : 500,
elapsedMs,
});
if (e?.code === "PROVIDER_UNAVAILABLE") {
return Response.json(
{ error: "Reasoning service is temporarily unavailable." },
{ status: 503 },
);
}
return Response.json(
{ error: e.message || "Unknown server error" },
{ error: "Reasoning request could not be completed." },
{ status: 500 },
);
}
}
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);
+1 -47
View File
@@ -1,49 +1,3 @@
import { getConfig } from "@/lib/config";
export async function GET() {
try {
const result = getConfig();
if (!result.ok) {
return Response.json({
configPresent: false,
baseUrl: null,
model: null,
reachable: false,
error: "Missing or invalid environment configuration",
}, { status: 500 });
}
const { OLLAMA_BASE_URL, OLLAMA_MODEL } = result.config;
// Test reachability with a short timeout
let reachable = false;
let reachError = null;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
signal: controller.signal
});
clearTimeout(timeout);
reachable = res.ok;
} catch (e) {
reachError = e.message || "Connection failed";
}
return Response.json({
configPresent: true,
baseUrl: OLLAMA_BASE_URL,
model: OLLAMA_MODEL,
reachable,
error: reachable ? null : (`Could not reach Ollama at ${OLLAMA_BASE_URL}: ${reachError || "timeout"}`),
});
} catch (e) {
return Response.json(
{ configPresent: false, error: e.message },
{ status: 500 }
);
}
return Response.json({ healthy: true }, { status: 200 });
}
@@ -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);
+41
View File
@@ -0,0 +1,41 @@
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");
// Derive redirect origin from proxy-forwarded headers when present,
// falling back to the direct request origin for local/direct access.
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto");
let origin;
if (forwardedHost && forwardedProto) {
// Nginx Proxy Manager (and similar proxies) set these headers.
// x-forwarded-host may contain host:port or just hostname; use as-is.
origin = `${forwardedProto}://${forwardedHost}`;
} else {
origin = requestUrl.origin;
}
const response = NextResponse.redirect(new URL("/", 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;
}
+35
View File
@@ -0,0 +1,35 @@
import React from "react";
import LegalPageLayout from "@/components/legal-page-layout";
export default function CookiesPage() {
return (
<LegalPageLayout title="Cookie Policy">
<p>This policy explains the browser storage Confidence Engine currently uses. LocalStorage and sessionStorage are browser storage technologies; they are not necessarily HTTP cookies.</p>
<section>
<h2 className="text-xl font-semibold text-gray-900">Authentication and session cookies</h2>
<p>Confidence Engine uses Supabase authentication and session cookies to provide secure sign-in and keep authenticated users signed in. These are essential to the authenticated service. Runtime cookie names and durations are managed by the authentication system.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Theme preference</h2>
<p><strong>confidence-engine-theme</strong> is stored in localStorage to remember your light or dark display preference. It remains until you change the preference or clear browser storage.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Introductory guidance preference</h2>
<p><strong>ce-facilitator-dismissed</strong> is stored in sessionStorage if you dismiss introductory guidance. It is session-scoped and remembers that choice while the browser session remains available.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">No analytics or advertising tracking</h2>
<p>Confidence Engine currently does not use advertising cookies, analytics cookies, tracking pixels, marketing cookies, or third-party browser tracking based on the current implementation.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Future changes</h2>
<p>If Confidence Engine later introduces non-essential cookies or similar technologies, this policy and any consent mechanism will be reconsidered as appropriate.</p>
</section>
</LegalPageLayout>
);
}
+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 };
});
}
+12 -3
View File
@@ -1,5 +1,8 @@
import React from "react";
import "./globals.css";
import ThemeToggle from "@/components/theme-toggle";
import LogoutButton from "@/components/logout-button";
import LegalNavigation from "@/components/legal-navigation";
export const metadata = {
title: "Confidence Engine",
@@ -9,7 +12,7 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50 text-gray-900">
<body className="flex min-h-screen flex-col bg-gray-50 text-gray-900">
<script
dangerouslySetInnerHTML={{
__html: `try { const saved = localStorage.getItem('confidence-engine-theme'); const theme = saved === 'dark' || saved === 'light' ? saved : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; } catch (_) {}`,
@@ -18,10 +21,16 @@ 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}
<div className="flex-1">{children}</div>
<footer className="app-chrome border-t border-gray-200/80 px-6 py-5">
<LegalNavigation />
</footer>
</body>
</html>
);
+104
View File
@@ -0,0 +1,104 @@
import LoginForm from "@/components/login-form";
export default function LoginPage() {
return (
<main className="mx-auto max-w-[1200px] px-6 py-16">
<div className="mx-auto flex flex-col gap-y-8 md:grid md:grid-cols-[minmax(0,1fr)_420px] md:gap-x-8 md:items-start">
{/* Left column wrapper — contents on mobile for flex ordering, block on desktop as one grid cell */}
<div className="contents md:block">
<section>
<h1 className="text-3xl font-bold tracking-tight text-teal-700">
Confidence Engine
</h1>
<div className="space-y-4 text-base leading-relaxed text-gray-700">
<h2 className="text-xl font-semibold tracking-tight text-gray-900">
Get clearer about what{"'"}s really going on.
</h2>
<p>
Confidence Engine helps you work through situations that feel
uncertain, complicated or difficult to act on.
</p>
<p>
Describe the situation in your own words. Confidence Engine will
help reconstruct what is known, what may be happening, and what
is still unclear then let you decide what to explore further.
</p>
<p className="text-gray-600">
It doesn{"'"}t try to make the decision for you. The aim is to
help you reach a clearer understanding so you can decide what to
do with more confidence.
</p>
</div>
</section>
<section className="space-y-4 mt-8 md:mt-8">
<h2 className="text-sm font-semibold uppercase tracking-[.18em] text-teal-700/70">
How it works
</h2>
<ol className="space-y-3 text-base leading-relaxed text-gray-700">
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">1.</span>
<div>
<strong>Describe your situation</strong>
<br />
<span className="text-gray-600">
As much or as little as you currently know.
</span>
</div>
</li>
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">2.</span>
<div>
<strong>Explore what{"'"}s unclear</strong>
<br />
<span className="text-gray-600">
Answer the questions that feel useful; skip the ones that
don{"'"}t.
</span>
</div>
</li>
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">3.</span>
<div>
<strong>Build your Current Understanding</strong>
<br />
<span className="text-gray-600">
Your picture of the situation develops as you learn more.
</span>
</div>
</li>
<li className="grid grid-cols-[1.5rem_1fr]">
<span className="font-bold">4.</span>
<div>
<strong>Stop when you have enough</strong>
<br />
<span className="text-gray-600">
You don{"'"}t have to answer everything. Your investigation
is saved so you can return later.
</span>
</div>
</li>
</ol>
<p className="text-sm italic text-gray-500">
Try it with something real.
<br />A decision you{"'"}re unsure about. A problem that doesn
{"'"}t quite make sense. A situation where you feel you may be
missing something.
</p>
</section>
</div>
{/* Right column — login card, explicitly positioned to top-right on desktop */}
<section className="md:col-start-2 md:row-start-1">
<LoginForm />
</section>
</div>
</main>
);
}
+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
+47
View File
@@ -0,0 +1,47 @@
import React from "react";
import LegalPageLayout from "@/components/legal-page-layout";
export default function PrivacyPage() {
return (
<LegalPageLayout title="Privacy Policy">
<p>This policy explains how Confidence Engine handles information when you use the service.</p>
<section>
<h2 className="text-xl font-semibold text-gray-900">Who is responsible</h2>
<p>Confidence Engine is operated by RDB Solutions Ltd., Palmeira Avenue Mansions, 19 Church Road, Hove, East Sussex, England, BN3 2FA. For privacy questions or deletion requests, contact <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Information we process</h2>
<p>We process your account email and authentication information, together with information you choose to enter into an investigation. This can include scenario descriptions, reconstructed SituationGraph material, Current Understanding, Open Questions, answers, Findings, Contributions, reports, and revision and timestamp metadata.</p>
<p>Please do not enter personal information that you do not need to provide, especially unnecessary information about other people.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Why we use it</h2>
<p>We use account information to provide secure access to your account. We use investigation information to save your work, let you return to it, and provide the reasoning features you request. Confidence Engine supports your understanding; it does not make decisions for you.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Storage and reasoning</h2>
<p>Authentication and investigation persistence are operated through our self-hosted Supabase and PostgreSQL infrastructure. Reasoning requests are processed through private Ollama/Qwen infrastructure used by the service. We also use functional browser storage for authentication sessions and interface preferences; see our Cookie Policy for details.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Retention and deletion</h2>
<p>Account and investigation information is retained while your account remains active. After 18 months without activity, we will contact you before deletion; if inactivity continues, account and investigation information may then be automatically deleted. You may request deletion at <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>.</p>
<p>Deleted information may remain temporarily in rotating backups until those backups expire.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Security and your rights</h2>
<p>We use access controls and technical measures appropriate to operating the service. You can contact us about access, correction, deletion, or other data-protection requests. You may also complain to the UK Information Commissioner&apos;s Office.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Age and changes</h2>
<p>Confidence Engine is for people aged 18 and over. We may update this policy as the service develops; the current version will be published on this page.</p>
</section>
</LegalPageLayout>
);
}
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import LegalPageLayout from "@/components/legal-page-layout";
export default function TermsPage() {
return (
<LegalPageLayout title="Terms of Use">
<p>Confidence Engine is operated by RDB Solutions Ltd. These Terms govern your use of the service.</p>
<section>
<h2 className="text-xl font-semibold text-gray-900">Eligibility and accounts</h2>
<p>You must be at least 18 years old to use Confidence Engine. Keep access to your email account and sign-in link secure, and provide accurate information when creating or using an account.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Using the service</h2>
<p>Use the service lawfully and responsibly. You are responsible for the information you enter and should avoid entering information about others unless it is necessary and appropriate to do so.</p>
<p>Confidence Engine facilitates understanding; it does not make decisions for you. AI or model-generated analysis may be incomplete, inaccurate, or unsuitable for your circumstances. It is not professional, legal, financial, medical, or other regulated advice. You remain responsible for your decisions and actions.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Availability</h2>
<p>We aim to keep the service available, but availability may vary. Reasoning functionality may occasionally be temporarily unavailable, and we may change, suspend, or withdraw parts of the service when reasonably necessary.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Your information and intellectual property</h2>
<p>You retain responsibility for your underlying scenarios and information. We do not claim ownership of that underlying material merely because you use the service. The Confidence Engine service, branding, and software remain the property of RDB Solutions Ltd. or its licensors.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Ending access and liability</h2>
<p>You may stop using the service and request account deletion at <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>. We may suspend or end access where reasonably necessary, including for misuse or security reasons.</p>
<p>Nothing in these Terms excludes liability that cannot legally be excluded. Subject to that, the service is provided for a controlled early release and we are not liable for indirect loss or for decisions you make using it.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900">Changes and law</h2>
<p>We may update these Terms by publishing the revised version on this page. These Terms are governed by the law of England and Wales. Questions can be sent to <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>.</p>
</section>
</LegalPageLayout>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import Link from "next/link";
export default function LegalNavigation() {
return (
<nav aria-label="Legal information" className="flex flex-wrap justify-center gap-x-5 gap-y-2 text-sm text-gray-500">
<Link href="/privacy" className="hover:text-teal-700">Privacy</Link>
<Link href="/terms" className="hover:text-teal-700">Terms</Link>
<Link href="/cookies" className="hover:text-teal-700">Cookies</Link>
</nav>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React from "react";
import Link from "next/link";
export default function LegalPageLayout({ title, children }) {
return (
<main className="mx-auto max-w-3xl px-6 py-12 sm:py-16">
<Link href="/login" className="text-sm font-medium text-teal-700 hover:underline">
Back to sign in
</Link>
<article className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-6 py-8 shadow-sm sm:px-10">
<h1 className="text-3xl font-bold tracking-tight text-teal-700">{title}</h1>
<div className="mt-8 space-y-7 text-base leading-relaxed text-gray-700">
{children}
</div>
</article>
</main>
);
}
+52
View File
@@ -0,0 +1,52 @@
"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 (
<section className="w-full shrink-0 space-y-6">
<div className="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>}
</div>
<div className="pt-3 space-y-3">
<p className="text-sm font-medium text-gray-700">Sign in to continue</p>
<p className="text-sm leading-relaxed text-gray-600">Enter your email and we{'\''}ll send you a secure sign-in link.</p>
</div>
<p className="text-xs text-gray-500">Your investigations are private to your account and saved so you can return to them later.</p>
</section>
);
}
+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>
);
}
+8 -2
View File
@@ -30,6 +30,9 @@ function isTechnicalSummary(summary) {
return false;
}
// ── Focused answer contract ──────────────────────────────────
const FOCUSED_ANSWER_MAX_LENGTH = 10000;
// ── Recovery state components (Phase 2) ───────────────────────
function ProviderUnavailableCard({ onRestart }) {
@@ -238,8 +241,11 @@ function FocusedQuestionBody({
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
<div data-testid="completed-narrative">
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="mt-3 rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} maxLength={FOCUSED_ANSWER_MAX_LENGTH} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-400">{focusedAnswer.length}/{FOCUSED_ANSWER_MAX_LENGTH}</span>
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
</div>
</div>
)}
+62 -15
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";
@@ -33,6 +33,28 @@ export async function submitScenarioForStartCase(fetchImpl, scenario) {
});
}
export function isUnavailableStartResponse(response, data) {
return response.status === 503 &&
data?.success === false &&
data?.error === "Reasoning service is temporarily unavailable.";
}
export function UnavailableStartPanel({ onRetry }) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<p className="font-medium">Confidence Engine is temporarily unavailable.</p>
<p className="mt-1">We couldn&apos;t process this right now. Your scenario is still here and you can try again.</p>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-lg border border-amber-400 px-4 py-2 text-sm font-medium text-amber-900 transition hover:bg-amber-100"
>
Retry
</button>
</div>
);
}
export async function submitAnswerForUpdateCase(
fetchImpl,
{ situationGraph, previousQuestion, answer, findings },
@@ -303,6 +325,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 +576,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 +604,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 +618,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 +640,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
focusedContributions,
findings,
investigationReport,
investigationRevision,
investigationRevision, hydrated,
]);
/* Restore facilitator dismiss preference (Experiment 05) ─── */
@@ -654,8 +694,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
updateStatus === "loading"
);
const handleSubmit = async (e) => {
e.preventDefault();
const handleStart = async () => {
setStatus("loading");
setResult(null);
setAnswer("");
@@ -680,7 +719,9 @@ 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 if (isUnavailableStartResponse(res, data)) {
setStatus("unavailable");
} else {
setStatus("error");
setCurrentUnderstanding(data.summary ?? null);
@@ -692,6 +733,11 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
}
};
const handleSubmit = (e) => {
e.preventDefault();
void handleStart();
};
const handleUpdate = async (e) => {
e.preventDefault();
@@ -767,7 +813,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);
@@ -781,7 +827,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
return (
<div className="space-y-6">
{/* ── Idle form for scenario input ─ */}
{!result?.situationGraph && status === "idle" && (
{!result?.situationGraph && (status === "idle" || status === "unavailable") && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Two-column landing workspace */}
@@ -846,6 +892,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
<p className="mt-3 text-xs italic text-gray-400">
You do not need all the answers yet.
</p>
{status === "unavailable" && <UnavailableStartPanel onRetry={handleStart} />}
</div>
</div>
@@ -947,7 +994,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
}}
onRestart={() => {
restartInvestigation(investigationId);
restartPersistedInvestigation();
setInvestigationRevision(0);
setStatus("idle");
setResult(null);
@@ -968,7 +1015,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 +1023,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
<div className="text-center">
<button
onClick={() => {
restartInvestigation(investigationId);
restartPersistedInvestigation();
setInvestigationRevision(0);
setScenario("");
setStatus("idle");
+1
View File
@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { useEffect, useState } from "react";
import {
readThemePreference,
+12
View File
@@ -0,0 +1,12 @@
# Confidence Engine — Deploy Environment (example)
#
# Copy to /opt/confidence-engine/deploy.env on CT 112.
# This file is NOT tracked by Git. It is owned/administered on the host.
#
# Build-time (baked into Docker image via --build-arg):
NEXT_PUBLIC_SUPABASE_URL=https://supabase.rdbcloud.co.uk
NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-supabase-anon-key
# Runtime (passed to container at start):
OLLAMA_BASE_URL=http://192.168.x.x:11434
OLLAMA_MODEL=replace-with-model-name
+337 -30
View File
@@ -3,10 +3,204 @@
> **Role:** Concise operational snapshot for resuming work today. Not a historical diary.
> The design evolution archive index at `docs/design-evolution/README.md` provides progressive loading of experiment history; load the relevant chapter only when a specific historical question requires it.
## Tester legal information
- Public Privacy, Terms, and Cookie pages and shared legal navigation are available at `/privacy`, `/terms`, and `/cookies`.
- The service is positioned as 18+. No cookie-consent banner is used because current browser storage is limited to essential authentication/session storage and functional UI preferences.
- Legal wording remains subject to appropriate professional review.
- Legal navigation is a single global normal-flow footer: it rests at the viewport bottom on short pages, long legal pages push it naturally below their content, and duplicate legal navigation was removed.
## Focused-investigation provider outage boundary
- Focused-investigation outage handling now sanitizes provider failure at the API boundary: unavailable focused reasoning returns a controlled HTTP 503, and raw provider/Ollama diagnostics no longer leave that boundary.
- The existing generic Retry UX remains unchanged. A live deployed outage had already proven investigation preservation.
- `/api/cases/start` and `/api/cases/update` outage sanitization remain separately unverified; this change does not claim those routes are fixed.
## Reasoning API browser error boundary hardened
- All reasoning HTTP error responses now sanitize raw provider diagnostics: `e.message` / raw internals never leak to the client.
- Verified on: `/api/cases/start`, `/api/focused-investigation/deconstruct`, `/api/cases/overview`, `/api/cases/synthesis`, `/api/analyse`.
- Provider/internal diagnostics remain server-side only.
- No reasoning semantics changed.
## v0.62d production Docker packaging — LIVE PROVEN
- `Dockerfile` — minimal multi-stage Alpine build (Node 22), Next.js standalone output mode
- `.dockerignore` — excludes dev artefacts, secrets, docs from build context
- `next.config.mjs` — added `output: 'standalone'` (required for lean production container)
- `.env.example` — reorganized: Supabase → Ollama → Mock sections; Ollama vars now tracked as deployment-relevant
- **npm production build: PASS** ✓
- **Docker image build on CT 112: PROVEN** — production Docker image builds successfully on CT 112, standalone Next.js container starts successfully.
- Production container `/api/health`: **PROVEN**`{"healthy":true}`, no private Ollama endpoint / model name / raw internal error exposed.
- No persistent application volume required.
- Supabase remains external, Ollama remains private and server-reachable from the deployment host.
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` as established by the implementation (baked into browser bundle).
- **Deployment: LIVE PROVEN** — manual Jenkins pipeline succeeded end-to-end (SHA-tagged image, container replaced, health check passed).
### v0.62d External Deployment (LIVE)
Confidence Engine is externally reachable at **https://confidence.rdbcloud.co.uk**.
Production topology:
```
Internet → HTTPS → Nginx Proxy Manager → CT 112 / Confidence Engine Docker container
```
CT 112 deployment details:
```
hostname: confidence-engine
LAN address: 192.168.68.73
repo: /opt/confidence-engine
container: confidence-engine
port: 3000
```
Docker-in-LXC has been proven. Docker image builds successfully on CT 112.
### v0.62d Magic-Link Login (LIVE)
External magic-link login has been proven through the deployed application at `https://confidence.rdbcloud.co.uk`. The reverse-proxy callback-origin defect was corrected; successful login now returns to the external Confidence Engine Portfolio rather than `0.0.0.0:3000`.
Auth email branding configured on external Supabase infrastructure (outside this repository):
- Confidence Engine sender name / subject / branded HTML body
- SPF PASS, DKIM PASS, DMARC PASS — recipient-side confirmed
- Missing original Message-ID / junk-folder observation remains a non-blocking external mail-deliverability note
### v0.62d Multi-user Boundary (LIVE)
Application-level isolation proven on deployed instance:
```
User A sees User A investigations
User B does not see User A investigations
```
### v0.62d Real Deployed Product Journey (LIVE)
Manually proved through the externally deployed application:
```
authenticate → Portfolio → create new investigation → reason through Qwen
→ server persistence → return to Portfolio → leave/return → investigation restored
```
Server-backed persistence remains authoritative. Legacy localStorage investigations remain ignored.
### v0.62d Manual Jenkins Deployment Pipeline (LIVE PROVEN)
Manual end-to-end pipeline proven:
```
manual Jenkins Build with Parameters → GIT_REF accepted
→ remote ref resolved to immutable SHA → Jenkins SSH credential bound
→ SSH to CT 112 → deployment script executed → exact SHA fetched/checked out
→ Docker image built and tagged by SHA → existing CE container replaced
→ /api/health polled → healthy response observed → DEPLOYMENT SUCCEEDED
```
Pipeline design:
- Manual trigger, runtime-selectable `GIT_REF`, immutable SHA deployment
- CT 112 as Docker build/runtime host, target-owned `/opt/confidence-engine/deploy.env`
- SHA-tagged Docker images, health-gated success, bounded rollback support
- No Docker registry, no automatic webhook deploy
**Path status:** success path = LIVE PROVEN. rollback path = IMPLEMENTED, NOT LIVE PROVEN.
Jenkins SCM branch used to load the Jenkinsfile is conceptually separate from the `GIT_REF` chosen for deployment.
## v0.63 First-Time Unauthenticated Product Framing
- login page now explains product purpose and investigation flow to first-time visitors without prior explanation
- user judgement/choice explicitly preserved ("It doesn't try to make the decision for you")
- existing magic-link authentication unchanged
- no broader onboarding/tutorial system introduced
### Mobile-first sign-in order (responsive layout fix)
- v0.63f corrected mobile reading order: proposition → sign-in card → supporting explanation
- CSS Grid with responsive column placement replaces original flexbox; three DOM sections ensure correct mobile stacking without duplicating content
- desktop two-column presentation (explanation left / sign-in right) preserved unchanged at `md` breakpoint and above
## Focused-investigation input hardening
- Focused answer now has a visible 10,000-character UI limit (maxLength + character counter).
- Server enforces matching 10,000-character bound on `/api/focused-investigation/deconstruct`.
- Other prompt-bearing fields retain explicit defensive bounds (2048 characters each).
- Oversized/malformed requests are rejected before reasoning with controlled HTTP 400.
- No punctuation/HTML/SQL-style content stripping introduced.
- Reasoning semantics unchanged.
## Pre-Tester Input Security Review
**Status:** closed — sufficient for controlled external-user testing. Not a general security audit, penetration test, or production-launch certification.
### SQL injection
- No raw request-driven SQL construction found.
- Persistence uses Supabase/PostgREST query-builder boundaries.
- SQL-character stripping / sanitisation is not warranted.
### XSS
- Normal user-controlled text is React-escaped.
- No user-controlled unsafe HTML sink found.
- HTML / script stripping of prose is not warranted.
### Reasoning error disclosure
- Browser-facing reasoning errors have been sanitised (commit `2cb2d55`).
### Focused user input
- Bounded to 10,000 characters server-side on `/api/focused-investigation/deconstruct`.
- UI exposes matching `maxLength` and character counter (commit `a6796c6`).
### Investigation snapshot size
- Authenticated persistence envelope currently lacks a whole-snapshot size ceiling.
- Not classified as an outstanding pre-tester blocker.
- Legitimate investigation size / turn depth is not yet known.
- No arbitrary product ceiling imposed before real-user evidence exists.
- Monitor snapshot growth later via metadata (serialized bytes / revision / contribution count) without logging investigation content.
- Malformed-envelope / runtime validation remains a separate future hardening opportunity.
### Prompt injection
- Assessed as **low risk under the current architecture** — not claimed to be impossible or "solved".
- Untrusted scenario / answer / persisted text can influence model reasoning.
- No evidence it gains application authority: no model-accessible arbitrary tools, DB targeting, auth identity control, ownership bypass, deletion, or arbitrary external requests found.
- Model/provider configuration is server-owned; model output passes through parsing/structured validation/domain boundaries before application mutation.
- No prompt-injection phrase / keyword filtering warranted; do not strip instruction-like natural-language content.
- Not a pre-tester blocker.
### Deferred non-security observations (backlog only — no code change)
- Orchestrator update flow contains an implicitly correct but indent-control-flow-unclear fall-through / else structure worth cleaning up later.
- Investigation overview validation may accept unexpected extra fields.
- Provider JSON recovery is intentionally/permissively capable of recovering malformed JSON; may merit a future robustness review.
### Tester-readiness position
Identified MEDIUM pre-tester security work is sufficiently addressed for controlled external-user testing. The application has **not** been generally security-audited, penetration-tested, or certified as production-secure or commercially launch-ready.
## CURRENT MVP DIRECTION
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:
@@ -43,39 +237,53 @@ Does the complete investigation process leave real people materially clearer abo
- The full apply-proposal owner suite remains known-red in independent pre-existing 60B.43 tests, so the changed Done-for-now boundary was verified through exact isolated owner tests. Zero live calls occurred during closeout. Next boundary: reuse the existing investigation and click Done for now once, observing cases/update and subsequent synthesis.
- Live UI proved Done for now briefly clarified the question, then server graph replacement reopened it: the client sends `preDoneGraph`, and the server previously had no deterministic closure owner. Episode-mode update now adds the selected `targetNodeId` to `resolvedNodeIds` only after successful semantic application; semantic no-ops and meaningful mutations remain valid, while failed episodes do not resolve the target and ordinary updates are unchanged. Zero live calls occurred during implementation. Next boundary: one live Done-for-now check on the existing investigation.
## Deployment automation (LIVE PROVEN)
Manual Jenkins deployment pipeline established, version-controlled, and LIVE PROVEN end-to-end.
- `Jenkinsfile` in repository root — Declarative Pipeline, three stages: Resolve → Deploy → Verify/result.
- `scripts/deploy-production.sh` — executes on CT 112; validates SHA, fetches Git ref, checks out exact commit, builds Docker image tagged by SHA, replaces container, polls `/api/health`, one-step rollback to prior image on failure.
- `deploy.env.example` — example of required runtime/build environment file (NOT tracked).
- Production env owned by `/opt/confidence-engine/deploy.env` on CT 112 (not in Git).
- `GIT_REF` is runtime-selectable (Jenkins parameter); resolves to immutable SHA before deployment.
- No Docker registry introduced. SHA-tagged images retained for rollback support.
- Jenkins job remains manually triggered — no automatic webhook deployment.
- No product behaviour changed by deployment automation.
**Success path: LIVE PROVEN.** **Rollback path: IMPLEMENTED, NOT LIVE PROVEN.**
## 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:** *(checkpoint commit — see git log for actual SHA)*
- **Working tree:** clean
## Initial reconstruction — current status
## Architectural Conclusion
**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.
**The original v0.62 product-platform objective is achieved:**
Current production default: `reconstruct-v0.5` prompt + canonical reconstruction schema + Zod validation via `z.toJSONSchema()`.
- Authenticated identity (magic-link via self-hosted Supabase)
- Authenticated reasoning APIs (private Ollama/Qwen, server-reachable)
- User-owned server persistence (Supabase `investigations`, RLS-scoped)
- Multi-user application behaviour (application-level isolation proven)
- Portable Docker runtime (Docker-in-LXC on CT 112)
- External HTTPS deployment (Nginx Proxy Manager → CE container)
- External magic-link authentication (callback fix, email branding proven)
- Manual one-touch Jenkins deployment (SHA-tagged, health-gated)
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.
**The following are NOT required and NOT justified before testing:**
**Frozen:** initial decomposition, prompt refinement, Qwen/Terra comparison — see CURRENT MVP DIRECTION above.
- Legacy localStorage migration — not required. Four development/test investigations will not be migrated; legacy data remains invisible to normal product flow.
- Additional SaaS/platform machinery — not justified before real user testing.
## Focused investigation — current status
> **Platform/deployment foundation work is complete enough to disappear into the background. The next Confidence Engine work should return to product/reasoning/user-learning priorities rather than continuing infrastructure expansion unless a real operational failure demands it.**
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
## Persistence
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 +309,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 +330,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
@@ -201,3 +409,102 @@ Previous focused-deconstruction semantic runs before the plumbing fix remain **i
2. Initial-reconstruction schema was supplied to focused-deconstruction call instead of its own contract
These are recorded as known contamination in the v0.61 archive chapter (`docs/design-evolution/ch19/initial-decomposition-v0.61.md`). The plumbing fix is complete and verified (48/48 tests).
## Tester-Readiness Findings — Recorded for Session Continuity
### Tester-readiness position
```
controlled external testing: GO
known pre-tester security blockers: none identified
next uncertainty worth reducing: real first-time-user product value, trust and independent usability
```
Do not claim commercial launch readiness, general production scalability, security certification or proven product-market fit.
### First external-user experiment
**Primary question:** Can a first-time user, without Rob guiding the investigation, use Confidence Engine to reach a Current Understanding that they regard as materially better than the way they framed the situation at the start?
**Tester evidence should prioritise:**
- did understanding materially improve?
- did they trust the changed understanding?
- could they reach it without Rob's help?
- was the experience usable?
**Post-use discovery questions (do not seed categories or mention a mental-health interpretation):**
- Who do you think this would be useful for?
- What kinds of situations would you use this for?
- Is there a situation in your own life or work where you can imagine coming back and using this?
### Landing-page positioning
- Current landing wording is deliberately unchanged for the first cohort.
- It may naturally be interpreted broadly, potentially including mental-health/anxiety-adjacent situations.
- This is an observation to learn from, not currently a defect.
- Use unprompted tester responses to learn what category/audience/use cases users believe Confidence Engine belongs to.
- Do not reposition before this evidence exists.
### Synthetic-user testing
- AI-driven first-time-user journeys may be useful as a pre-human stress test.
- Preferred conceptual separation: **synthetic first-time user → real Confidence Engine journey → independent evaluator**.
- Synthetic testing can expose reasoning/UX/systematic failures.
- It does NOT replace human evidence about genuine trust, changed understanding, usefulness, repeat use or willingness to return.
- No synthetic-testing implementation is currently required before human testing.
### Reasoning concurrency
- Current CE application has **no** server-side per-user reasoning concurrency protection.
- No CE-level global reasoning concurrency limit.
- Multiple tabs/users can reach provider concurrently.
- Ollama/provider concurrency and queue behaviour are not controlled by CE repository code.
- Do not invent queue/latency behaviour from the 300-second request timeout.
- Controlled cohort of roughly 510 invited testers: **not currently a release blocker**.
- Observe actual behaviour before designing queue/semaphore infrastructure.
- Before wider/public access, reasoning-resource concurrency protection should be reconsidered.
### Rate limiting
- No application-level per-user reasoning rate limit currently exists.
- Generic API rate limiting is not required for the controlled tester cohort.
- Wider/public access should revisit reasoning-specific abuse/resource protection.
- Future protection should target scarce reasoning/provider capacity rather than indiscriminately throttling cheap authenticated persistence/read operations.
- `withAuthenticatedApi` establishes authenticated identity but should not automatically become a generic reasoning-throttling owner.
### Accidental duplicate submission (deferred small product/UX item)
- Trace indicates initial Analyse action is **not disabled** by `status === "loading"`.
- Rapid same-page duplicate submission may therefore be possible.
- This is distinct from server-side rate/concurrency protection.
- Retain for a future bounded correction; do not change production code now.
### Future operational evidence
If concurrency instrumentation becomes necessary, prefer metadata only:
- reasoning endpoint/action
- request start/end or duration
- number of concurrent active reasoning calls
- result/timeout classification
Do not log investigation content merely for capacity measurement.
### Investigation growth
Preserve the existing decision:
- No arbitrary whole-investigation snapshot ceiling before real-user evidence.
- Legitimate turn depth and mature investigation size are unknown.
- Later observation may use serialized snapshot bytes, revision, contribution count and finding count without recording content.
### Existing deferred hardening/cleanup (not tester blockers)
Ensure these remain visible and are not accidentally promoted to tester blockers:
- malformed persistence-envelope/runtime validation
- investigation overview unexpected-extra-field validation
- provider JSON recovery robustness
- orchestrator update-flow control/indentation clarity
- whole-snapshot size decision pending real-user evidence
- reasoning concurrency/rate protection before wider access
- initial Analyse duplicate-submit prevention
+138 -4
View File
@@ -1,5 +1,47 @@
# Current Project State — Confidence Engine
## Tester Legal Information
- Public Privacy, Terms, and Cookie pages and shared legal navigation are available at `/privacy`, `/terms`, and `/cookies`.
- The service is positioned as 18+. No cookie-consent banner is used because current browser storage is limited to essential authentication/session storage and functional UI preferences.
- Legal wording remains subject to appropriate professional review.
## Focused-Investigation Provider Outage Boundary
- Focused-investigation outage handling now sanitizes provider failure at the API boundary: unavailable focused reasoning returns a controlled HTTP 503, and raw provider/Ollama diagnostics no longer leave that boundary.
- The existing generic Retry UX remains unchanged. A live deployed outage had already proven investigation preservation.
- `/api/cases/start` and `/api/cases/update` outage sanitization remain separately unverified; this change does not claim those routes are fixed.
## v0.62d Production Docker Packaging — LIVE PROVEN
- `Dockerfile` created: multi-stage Alpine build (Node 22), Next.js standalone output, configurable port 3000, health-check boundary via `/api/health`
- `.dockerignore` created: excludes `node_modules`, `.next`, secrets (`env.*.local`), docs, IDE, OS artefacts
- `next.config.mjs`: added `output: 'standalone'` for lean production container (only config change required)
- `.env.example`: reorganized; Ollama variables now tracked as deployment-relevant env vars
- npm production build: PASS ✓
- Docker image build on CT 112: **PROVEN** — builds successfully, standalone container starts successfully.
- Production container `/api/health`: **PROVEN**`{"healthy":true}`, no private Ollama model name / raw internal error exposed.
- No persistent application volume required.
- Supabase remains external; Ollama remains private and server-reachable from deployment host.
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` (baked into browser bundle).
- **Deployment: LIVE PROVEN** — manual Jenkins pipeline succeeded end-to-end.
### External Deployment (LIVE)
Confidence Engine is externally reachable at **https://confidence.rdbcloud.co.uk**.
Topology: Internet → HTTPS → Nginx Proxy Manager → CT 112 / Confidence Engine Docker container.
CT 112: hostname `confidence-engine`, LAN `192.168.68.73`, repo `/opt/confidence-engine`, container `confidence-engine`, port `3000`.
### External Magic-Link Login (LIVE)
Magic-link authentication proven through deployed instance. Callback-origin defect corrected; login returns to external portfolio rather than `0.0.0.0:3000`. Email branding configured on external Supabase (sender name, subject, branded HTML body). SPF/DKIM/DMARC confirmed recipient-side.
### Multi-user Boundary (LIVE)
Application-level isolation proven on deployed instance — User A does not see User B investigations and vice versa.
> Created by Experiment 27. This document is the starting point for any fresh session working on the Confidence Engine. Read this first, then follow the routing table below to task-specific references.
## 1. What the Confidence Engine Is
@@ -34,6 +76,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 +121,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
@@ -170,6 +216,35 @@ First document to read: **`docs/current-handoff.md`** (methodology continuity +
Implementation status last checked against source: Experiment 43 + v0.61 apparatus (tsx helper, reconstruction-only seam, focused-deconstruction schema fix). Multi-investigation architecture verified at v0.60g2+ and structurally complete. The current-state document was verified as accurate by focused code inspection of API routes, orchestrator imports/calls, and cross-module traces for all passive classifiers. No corrections were required.
## Deployment Automation — LIVE PROVEN
A manual Jenkins deployment pipeline has been established, version-controlled, and LIVE PROVEN end-to-end.
- **Jenkinsfile** (root) — Declarative Pipeline with three stages: `Resolve``Deploy``Verify/result`.
- **Parameter:** `GIT_REF` (string) — user-supplied Git ref (branch, tag, or SHA). Blank value fails clearly.
- **Resolution:** Jenkins resolves the ref to an exact commit SHA via `git ls-remote origin` before deployment. The SHA is deployed immutably.
- **Target host:** CT 112 (`confidence-engine`, 192.168.68.73) at `/opt/confidence-engine`.
- **Docker image:** tagged `confidence-engine:<sha>`, built on CT 112, no registry required.
- **Environment:** production values in `/opt/confidence-engine/deploy.env` on CT 112 (not in Git). `deploy.env.example` provided as reference.
- **Health check:** polls `http://127.0.0.1:3000/api/health`; requires `{"healthy":true}` within 60s.
- **Rollback:** one-step rollback to the previous container image on failure (if available). **IMPLEMENTED, NOT LIVE PROVEN.**
- **Jenkins job:** configured and operational. First successful deployment recorded with explicit "DEPLOYMENT SUCCEEDED" output.
- **Jenkins SCM branch** used to load the Jenkinsfile is conceptually separate from the `GIT_REF` chosen for deployment.
### Pipeline design notes
- Manual trigger only — no automatic webhook deploy.
- No Docker registry introduced; SHA-tagged images retained on CT 112 for rollback support.
- No product behaviour changed by deployment automation.
## 9. First-Time Unauthenticated Landing/Login Framing
- login page now explains product purpose and investigation flow to first-time visitors without prior explanation
- user judgement/choice explicitly preserved ("It doesn't try to make the decision for you")
- existing magic-link authentication unchanged
- no broader onboarding/tutorial system introduced
- verified via Playwright semantic locators (heading, text content, form controls) and responsive viewport
## 10. Post-v0.8 Methodology Learning
### Durable methodology principles (2026-08-19)
@@ -212,6 +287,65 @@ The following material learnings are carried forward as durable context for safe
User-selected/active investigation ownership must survive substantive ties and
question-formulation rejection. (Already documented in `docs/current-handoff.md`.)
## 11. Pre-Tester Input Security Review (closed)
**Status:** closed — sufficient for controlled external-user testing. Not a general security audit, penetration test, or production-launch certification.
### SQL injection
- No raw request-driven SQL construction found; persistence uses Supabase/PostgREST query-builder boundaries.
### XSS
- Normal user-controlled text is React-escaped; no unsafe HTML sink found.
### Reasoning error disclosure
- Browser-facing reasoning errors sanitised (commit `2cb2d55`).
### Focused input bound
- 10,000-character server-side + UI boundary on focused investigation (commit `a6796c6`).
### Investigation snapshot size envelope / investigation growth
- Authenticated persistence lacks a whole-snapshot size ceiling.
- No arbitrary whole-investigation snapshot ceiling before real-user evidence.
- Legitimate turn depth and mature investigation size are unknown.
- Later observation may use serialized snapshot bytes, revision, contribution count and finding count without recording content.
- Not a pre-tester blocker.
### Prompt injection
- **Low risk under the current architecture.** Untrusted text can influence model reasoning but no evidence it gains application authority. No model-accessible arbitrary tools, DB targeting, auth control, or privileged side effects found. Output passes structured validation before application mutation. Not a pre-tester blocker.
### Deferred non-security observations (backlog only — no code change)
- Orchestrator update flow indentation/control-flow clarity deferred.
- Investigation overview validation may accept unexpected extra fields.
- Provider JSON recovery permissiveness deferred as future robustness review.
- Malformed persistence-envelope/runtime validation deferred.
- Initial Analyse duplicate-submit prevention deferred (action not disabled by `status === "loading"`).
### Tester-readiness position
```
controlled external testing: GO
known pre-tester security blockers: none identified
next uncertainty worth reducing: real first-time-user product value, trust and independent usability
```
Do not claim commercial launch readiness, general production scalability, security certification or proven product-market fit.
### Reasoning concurrency (deferred)
- No server-side per-user reasoning concurrency protection; no CE-level global limit.
- Multiple tabs/users can reach provider concurrently — Ollama/provider behaviour not controlled by CE repository code.
- Controlled cohort of ~510 invited testers: **not a release blocker**.
- Before wider/public access, reasoning-resource concurrency protection should be reconsidered.
- If instrumentation needed later: prefer metadata only (endpoint/action, request start/end or duration, concurrent active call count, result/timeout classification). Do not log investigation content for capacity measurement.
### Rate limiting (deferred)
- No application-level per-user reasoning rate limit currently exists.
- Not required for controlled tester cohort; revisit before wider/public access.
- Future protection should target scarce reasoning/provider capacity, not indiscriminately throttle cheap authenticated persistence/read operations.
---
### RTO learning from Experiments 1417
Since the handoff document was written, further learning has emerged from Return-to-Origin work (RTO.1417):
+3 -1
View File
@@ -100,6 +100,7 @@ export async function analyseScenario(scenario, opts = {}) {
"500",
e.providerApiPath,
e.providerExecution,
e.code,
);
}
@@ -175,7 +176,7 @@ function tryValidateAgainstSchema(data, schema) {
// ── Result builders ──────────────────────────────────
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution) {
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution, code) {
return {
success: false,
error: message,
@@ -187,6 +188,7 @@ function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath,
statusCode,
providerApiPath,
providerExecution,
code,
};
}
+1
View File
@@ -382,6 +382,7 @@ export async function startCase(body, dependencies = {}) {
providerApiPath: analysis.providerApiPath ?? undefined,
providerExecution: analysis.providerExecution ?? undefined,
rawResponse: analysis.rawResponse ?? undefined,
code: analysis.code ?? undefined,
statusCode: Number(analysis.statusCode) || 502,
};
}
+3
View File
@@ -439,6 +439,9 @@ class OllamaLlmProvider {
`- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` +
`- Check Ollama logs: \`ollama serve\` or look at your system logs`
);
if (e?.name === "AbortError" || e instanceof TypeError) {
error.code = "PROVIDER_UNAVAILABLE";
}
error.providerApiPath = apiUsed;
error.providerExecution = providerExecution;
throw error;
+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", "/api/health", "/privacy", "/terms", "/cookies"];
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).*)"] };
+4 -1
View File
@@ -1,3 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: 'standalone',
};
export default nextConfig;
+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",
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Confidence Engine — Production Deployment Script
#
# Executes on CT 112 at /opt/confidence-engine.
# Input: $1 = exact commit SHA (validated)
#
# Environment:
# DEPLOY_DIR - target repository path (defaults /opt/confidence-engine)
# HEALTH_URL - health check endpoint (defaults http://127.0.0.1:3000/api/health)
#
# Reads deploy.env from $DEPLOY_DIR/deploy.env for runtime values.
set -euo pipefail
# ── Parameters ──────────────────────────────────────────────────────
DEPLOY_SHA="${1:?Error: missing commit SHA argument}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/confidence-engine}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/api/health}"
# ── Validation ──────────────────────────────────────────────────────
if [[ ${#DEPLOY_SHA} -lt 7 ]]; then
echo "ERROR: SHA must be at least 7 hex characters."
exit 1
fi
if ! echo "$DEPLOY_SHA" | grep -qE '^[0-9a-fA-F]+$'; then
echo "ERROR: SHA contains non-hex characters."
exit 1
fi
echo "Deploying SHA: $DEPLOY_SHA"
cd "$DEPLOY_DIR" || { echo "ERROR: Cannot cd to $DEPLOY_DIR"; exit 1; }
# ── Safety checks ───────────────────────────────────────────────────
# deploy.env must exist on the host (not tracked by Git)
if [[ ! -f deploy.env ]]; then
echo "DEPLOYMENT BLOCKED - deploy.env not found at deploy.env"
exit 1
fi
# Check for uncommitted changes that would block safe checkout
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
echo "DEPLOYMENT BLOCKED - target working tree has uncommitted changes"
exit 1
fi
if [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
echo "DEPLOYMENT BLOCKED - target working tree has untracked files"
exit 1
fi
# ── Fetch and verify SHA ────────────────────────────────────────────
echo "Fetching latest refs from origin..."
git fetch origin >/dev/null 2>&1 || {
echo "ERROR: git fetch origin failed."
exit 1
}
if ! git rev-parse --verify "$DEPLOY_SHA" >/dev/null 2>&1; then
echo "ERROR: SHA $DEPLOY_SHA not found in repository after fetch."
exit 1
fi
# ── Detached checkout (never modifies working tree or remote) ───────
echo "Checking out exactly $DEPLOY_SHA..."
git checkout --detach "$DEPLOY_SHA" >/dev/null 2>&1 || {
echo "ERROR: Could not checkout SHA $DEPLOY_SHA."
exit 1
}
# Verify the detached HEAD matches our deploy SHA
CURRENT_SHA="$(git rev-parse HEAD)"
if [[ "${CURRENT_SHA%"${CURRENT_SHA#?}"}" != "${DEPLOY_SHA%"${DEPLOY_SHA#?}"}" ]] || \
[[ "$CURRENT_SHA" != "$DEPLOY_SHA" && "${CURRENT_SHA:0:7}" != "${DEPLOY_SHA:0:7}" ]]; then
echo "ERROR: Checked out SHA $CURRENT_SHA does not match requested $DEPLOY_SHA."
exit 1
fi
# ── Load runtime environment ────────────────────────────────────────
if [[ -f deploy.env ]]; then
set -a
# shellcheck disable=SC1091
source deploy.env
set +a
else
echo "ERROR: deploy.env not found at $DEPLOY_DIR/deploy.env"
exit 1
fi
# ── Build Docker image ──────────────────────────────────────────────
IMAGE_TAG="confidence-engine:${DEPLOY_SHA}"
echo "Building Docker image ${IMAGE_TAG}..."
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL="${NEXT_PUBLIC_SUPABASE_URL}" \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${NEXT_PUBLIC_SUPABASE_ANON_KEY}" \
-t "${IMAGE_TAG}" \
.
echo "Image ${IMAGE_TAG} built successfully."
# ── Container replacement ───────────────────────────────────────────
CONTAINER_NAME="confidence-engine"
# Record previous image before replacing container
PREV_IMAGE=""
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
PREV_IMAGE="$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "")"
fi
echo "Stopping existing container (if running)..."
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
echo "Starting new container from ${IMAGE_TAG}..."
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 3000:3000 \
-e OLLAMA_BASE_URL="${OLLAMA_BASE_URL}" \
-e OLLAMA_MODEL="${OLLAMA_MODEL}" \
"${IMAGE_TAG}"
echo "Container $CONTAINER_NAME started."
# ── Health check polling ────────────────────────────────────────────
HEALTH_CHECK_TIMEOUT=60
HEALTH_CHECK_INTERVAL=3
ELAPSED=0
echo "Waiting for health check at ${HEALTH_URL}..."
while [[ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]]; do
if curl -sf "$HEALTH_URL" | grep -q '"healthy"[[:space:]]*:[[:space:]]*true'; then
echo ""
echo "============================================="
echo "DEPLOYMENT SUCCEEDED"
echo "Deployed SHA: $DEPLOY_SHA"
echo "Current image: $(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo 'unknown')"
echo "============================================="
exit 0
fi
sleep "$HEALTH_CHECK_INTERVAL"
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
done
echo ""
echo "============================================="
echo "DEPLOYMENT FAILED - Health check timed out after ${HEALTH_CHECK_TIMEOUT}s"
echo "============================================="
# ── Rollback (one attempt) ──────────────────────────────────────────
if [[ -n "$PREV_IMAGE" ]]; then
echo ""
echo "Attempting rollback to previous image: $PREV_IMAGE"
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 3000:3000 \
-e OLLAMA_BASE_URL="${OLLAMA_BASE_URL}" \
-e OLLAMA_MODEL="${OLLAMA_MODEL}" \
"$PREV_IMAGE"
if [[ $? -eq 0 ]]; then
echo "DEPLOYMENT FAILED - ROLLED BACK to $PREV_IMAGE"
else
echo "DEPLOYMENT FAILED - ROLLBACK ALSO FAILED"
fi
else
echo "No previous image available for rollback."
fi
echo "DEPLOYMENT FAILED - MANUAL RECOVERY REQUIRED"
exit 1
@@ -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());
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vitest";
// ── Mock domain seam and provider at module level ────
const mockAnalyseScenario = vi.fn();
vi.mock("@/lib/analysis", () => ({
analyseScenario: (...args) => mockAnalyseScenario(...args),
PROMPT_VERSIONS: ["v1"],
DEFAULT_PROMPT_VERSION: "v1",
}));
vi.mock("@/lib/llm/provider.js", () => ({
getProvider: () => ({}),
getProviderModelName: () => "gpt-5.6-terra",
}));
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
// ── Helpers ─────────────────────────────────────────
function makeValidScenario() {
return "The supplier changed delivery schedules without notice, causing our production line to halt.";
}
function makeRequest(body) {
return new Request("http://localhost/api/analyse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
// ── Route tests — valid POST ────────────────────────
describe("POST /api/analyse — success contract", () => {
it("returns structured analysis on success", async () => {
mockAnalyseScenario.mockResolvedValue({
success: true,
inputClassification: "manufacturing",
reconstruction: { summary: "Validated reconstruction summary" },
evidence: [],
nextQuestion: null,
modelName: "gpt-5.6-terra",
responseDurationMs: 1200,
validationStatus: "passed",
promptVersion: "v1",
});
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(200);
const data = await res.json();
expect(typeof data.inputClassification).toBe("string");
expect(data.reconstruction.summary).toBe("Validated reconstruction summary");
});
});
// ── Route tests — request validation ────────────────
describe("POST /api/analyse — request validation", () => {
it("missing scenario → 400", async () => {
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({}));
expect(res.status).toBe(400);
const data = await res.json();
expect(data).toEqual({ error: "Request must include a 'scenario' string field" });
});
it("null scenario → 400", async () => {
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: null }));
expect(res.status).toBe(400);
});
it("non-string scenario → 400", async () => {
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: 123 }));
expect(res.status).toBe(400);
});
});
// ── Route tests — error handling ────────────────────
describe("POST /api/analyse — error boundary", () => {
it("domain seam throws PROVIDER_UNAVAILABLE → sanitized 503 with generic message", async () => {
const err = Object.assign(
new Error("Ollama /api/generate request timed out after 5 minutes"),
{ code: "PROVIDER_UNAVAILABLE", providerApiPath: "/api/generate" },
);
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(503);
const data = await res.json();
expect(data.error).toBe("Reasoning service is temporarily unavailable.");
});
it("domain seam throws with diagnostics → sanitized response, preserved status", async () => {
const rawResponse = `{"reconstruction":{"observedStates":[{"id":"obs-1"${"x".repeat(2500)}}]}}`;
const err = Object.assign(
new Error("Provider unavailable"),
{
statusCode: 502,
providerApiPath: "/v1/responses",
providerExecution: { chatRequestAttempted: true },
rawResponse,
},
);
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(502);
const data = await res.json();
expect(data.error).toBe("Reasoning request could not be completed.");
expect(JSON.stringify(data)).not.toMatch(/provider unavailable|generate|llama3|rawResponse/i);
});
it("domain seam throws without statusCode → 500", async () => {
mockAnalyseScenario.mockImplementationOnce(async () => { throw new Error("unknown error"); });
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe("Reasoning request could not be completed.");
});
it("domain seam throws validation failure → preserved status", async () => {
const err = Object.assign(new Error("Invalid input"), { statusCode: 400 });
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("Reasoning request could not be completed.");
});
it("domain seam returns failure result with diagnostics → sanitized response", async () => {
mockAnalyseScenario.mockResolvedValue({
success: false,
error: "Provider unavailable",
diagnostics: { modelName: "llama3" },
statusCode: 502,
analysisErrors: ["reconstruction: Required"],
validationIssues: [{ path: ["reconstruction"], code: "invalid_type", message: "Required" }],
providerApiPath: "/api/generate",
providerExecution: { generateRequestAttempted: true },
rawResponse: '{"observedStates":[{"id":"x"}]}',
});
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(502);
const data = await res.json();
expect(data).toEqual({ error: "Reasoning request could not be completed." });
expect(JSON.stringify(data)).not.toMatch(/provider unavailable|generate|llama3|rawResponse/i);
});
it("domain seam throws without exposing raw provider/internal diagnostics", async () => {
const err = Object.assign(
new Error("Internal connection reset by peer — host=10.0.0.5:8080 key=sk-abc"),
{ statusCode: 502, providerApiPath: "/internal/chat", providerExecution: { chatRequestAttempted: true } },
);
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(502);
const data = await res.json();
expect(JSON.stringify(data)).not.toMatch(/10\.0\.0\.5|8080|sk-abc|providerApiPath|providerExecution|Internal connection reset/i);
});
it("domain seam returns failed analysis object → not spread into response", async () => {
mockAnalyseScenario.mockResolvedValue({
success: false,
error: "Analysis failed",
statusCode: 500,
failedAnalysisObject: { raw: true, internal: "diagnostics" },
});
const { POST } = await import("@/app/api/analyse/route.js");
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
expect(res.status).toBe(500);
const data = await res.json();
expect(data).toEqual({ error: "Reasoning request could not be completed." });
expect(JSON.stringify(data)).not.toMatch(/failedAnalysisObject|internal|diagnostics/i);
});
});
+37 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const mockSynthesize = vi.fn();
@@ -11,6 +11,10 @@ vi.mock("@/lib/llm/provider.js", () => ({
getProviderModelName: () => "gpt-5.6-terra",
}));
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
describe("POST /api/cases/overview provider routing", () => {
beforeEach(() => mockSynthesize.mockClear());
@@ -26,4 +30,36 @@ describe("POST /api/cases/overview provider routing", () => {
expect(response.status).toBe(200);
expect(mockSynthesize.mock.calls[0][1]).toMatchObject({ modelName: "gpt-5.6-terra" });
});
it("sanitizes provider failure details", async () => {
const error = Object.assign(
new Error("Ollama /api/generate returned 500 from private host"),
{ statusCode: 502 },
);
mockSynthesize.mockImplementationOnce(async () => {
throw error;
});
const { POST } = await import("@/app/api/cases/overview/route.js");
const response = await POST(new Request("http://localhost/api/cases/overview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ situationGraph: { nodes: [], edges: [] } }),
}));
expect(response.status).toBe(502);
const rawData = await response.text();
const data = JSON.parse(rawData);
expect(data.success).toBe(false);
expect(data.stage).toBe("provider");
expect(data).toEqual({
success: false,
stage: "provider",
error: "Reasoning request could not be completed.",
});
// Prove raw provider diagnostics are NOT exposed at the route boundary
expect(rawData).not.toContain(error.message);
});
});
+51 -23
View File
@@ -1,11 +1,22 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockStartCase = vi.fn();
let realStartCase;
let warnSpy;
let errorSpy;
vi.mock("@/lib/graph/orchestrator.js", () => ({
startCase: (...args) => mockStartCase(...args),
vi.mock("@/lib/config.js", () => ({
getConfig: () => ({ ok: true }),
}));
vi.mock("@/lib/graph/orchestrator.js", async (importOriginal) => {
const actual = await importOriginal();
realStartCase = actual.startCase;
return { ...actual, startCase: (...args) => mockStartCase(...args) };
});
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
describe("app/api/cases/start route", () => {
@@ -123,7 +134,42 @@ describe("app/api/cases/start route", () => {
expect(errorSpy).not.toHaveBeenCalled();
});
it("returns provider/internal failures as 5xx without stack traces", async () => {
it("returns a sanitized 503 when the provider is unavailable", async () => {
const unavailableError = Object.assign(
new Error("Ollama /api/generate request timed out after 5 minutes"),
{
code: "PROVIDER_UNAVAILABLE",
providerApiPath: "/api/generate",
providerExecution: { generateRequestAttempted: true },
},
);
const reconstructionProvider = {
generateReconstruction: vi.fn().mockRejectedValue(unavailableError),
};
mockStartCase.mockImplementation((body) => realStartCase(body, {
reconstructionProvider,
reconstructionModelName: "configured-model",
}));
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: "Scenario text" }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(503);
const body = await response.json();
expect(body).toEqual({
success: false,
error: "Reasoning service is temporarily unavailable.",
});
expect(JSON.stringify(body)).not.toMatch(/ollama|generate|timed out/i);
});
it("sanitizes provider/internal failures at the browser boundary", async () => {
const rawResponse = `{"reconstruction":{"observedStates":[{"id":"obs-1"${"x".repeat(2500)}}]}}`;
mockStartCase.mockResolvedValue({
success: false,
@@ -161,26 +207,8 @@ describe("app/api/cases/start route", () => {
expect(response.status).toBe(502);
const body = await response.json();
expect(body).toHaveProperty("rawResponse");
expect(body.rawResponse).toBe(rawResponse);
expect(body.rawResponse.length).toBeGreaterThan(2000);
expect(body.analysisErrors).toEqual(["reconstruction: Required"]);
expect(body.validationIssues).toEqual([
expect.objectContaining({
path: ["reconstruction", "observedStates", 2, "description"],
code: "invalid_type",
message: "Required",
expected: "string",
received: "undefined",
}),
]);
expect(body.providerApiPath).toBe("/api/generate");
expect(body.providerExecution).toEqual({
chatCapabilityDetected: false,
chatRequestAttempted: false,
chatRequestSucceeded: false,
generateRequestAttempted: true,
});
expect(body).toEqual({ success: false, error: "Reasoning request could not be completed." });
expect(JSON.stringify(body)).not.toMatch(/provider unavailable|generate|llama3|rawResponse/i);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
"[api/cases/start] error response",
@@ -13,6 +13,10 @@ vi.mock("@/lib/llm/provider.js", () => ({
getProviderModelName: () => "gpt-5.6-terra",
}));
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
// ── Helpers ─────────────────────────────────────────────────
function makeValidGraph() {
@@ -110,10 +114,13 @@ describe("POST /api/cases/synthesis — error cases", () => {
});
it("domain seam throws with statusCode → mapped status", async () => {
mockSynthesize.mockRejectedValue(new Error("Provider failed"));
// Add statusCode property to the error object after creation
const err = Object.assign(new Error("Provider failed"), { statusCode: 502 });
mockSynthesize.mockRejectedValue(err);
const err = Object.assign(
new Error("Ollama /api/generate returned 500 from private host"),
{ statusCode: 502 },
);
mockSynthesize.mockImplementationOnce(async () => {
throw err;
});
const { POST } = await import("@/app/api/cases/synthesis/route.js");
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
@@ -122,10 +129,17 @@ describe("POST /api/cases/synthesis — error cases", () => {
const data = await res.json();
expect(data.success).toBe(false);
expect(data.stage).toBe("provider");
expect(data).toEqual({
success: false,
stage: "provider",
error: "Reasoning request could not be completed.",
});
});
it("domain seam throws without statusCode → 500", async () => {
mockSynthesize.mockRejectedValue(new Error("unknown error"));
mockSynthesize.mockImplementationOnce(async () => {
throw new Error("unknown error");
});
const { POST } = await import("@/app/api/cases/synthesis/route.js");
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
@@ -138,7 +152,9 @@ describe("POST /api/cases/synthesis — error cases", () => {
it("domain seam throws 400 → mapped to 400", async () => {
const err = Object.assign(new Error("Invalid input"), { statusCode: 400 });
mockSynthesize.mockRejectedValue(err);
mockSynthesize.mockImplementationOnce(async () => {
throw err;
});
const { POST } = await import("@/app/api/cases/synthesis/route.js");
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
+136
View File
@@ -0,0 +1,136 @@
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(),
exchangeCodeForSession: vi.fn().mockResolvedValue(undefined),
},
}),
}));
describe("auth callback redirect origin", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses forwarded host/proto for redirect when behind proxy", async () => {
const { GET } = await import("@/app/auth/callback/route.js");
const request = new Request("http://0.0.0.0:3000/auth/callback?code=abc123", {
headers: {
"x-forwarded-host": "confidence.rdbcloud.co.uk",
"x-forwarded-proto": "https",
},
});
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://confidence.rdbcloud.co.uk/");
});
it("falls back to request origin when no forwarded headers", async () => {
const { GET } = await import("@/app/auth/callback/route.js");
const request = new Request("http://localhost:3000/auth/callback?code=xyz");
const response = await GET(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/");
});
});
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 and does not leak config details", async () => {
mockGetConfig.mockReturnValue({ ok: true, config: {} });
const { GET } = await import("@/app/api/health/route.js");
const response = await GET();
expect(mockGetAuthenticatedUser).not.toHaveBeenCalled();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ healthy: true });
});
it("remains healthy when reasoning configuration is missing", async () => {
mockGetConfig.mockReturnValue({ ok: false });
const { GET } = await import("@/app/api/health/route.js");
const response = await GET();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ healthy: true });
});
it("does not convert /api/health to 401 via middleware when unauthenticated", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });
const { middleware } = await import("@/middleware.js");
const response = await middleware(new NextRequest("http://localhost:3000/api/health"));
expect(response.status).toBe(200);
});
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");
});
});
+204 -2
View File
@@ -9,6 +9,10 @@
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation";
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
// ── helpers ──────────────────────────────────────────────────────────────
function makeMockProvider(inventedTargetNodeId) {
@@ -356,7 +360,7 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
expect(json.providerExecution).toBeUndefined();
});
it("preserves the 500 provider-failure contract while logging structural diagnostics", async () => {
it("sanitizes generic provider failures while logging structural diagnostics", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
@@ -379,7 +383,7 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
}),
}));
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({ error: "provider failed" });
await expect(response.json()).resolves.toEqual({ error: "Reasoning request could not be completed." });
expect(errorSpy).toHaveBeenCalledWith(
"[api/focused-investigation/deconstruct] provider failure",
expect.objectContaining({ targetNodeId: "node-id", providerApiPath: "/v1/responses" }),
@@ -389,6 +393,204 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
}
});
it("rejects oversized focused answer with 400 and does not reach reasoning seam", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {},
providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const largeAnswer = "x".repeat(10001);
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: largeAnswer,
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/answer.*exceeds maximum length/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("accepts focused answer at exact server max (10000) and reaches reasoning seam", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {
targetNodeId: "node-id",
observations: [],
uncertainties: [],
assumptions: [],
relationships: [],
possibleFollowUpQuestions: [],
},
providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const exactMaxAnswer = "x".repeat(10000);
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: exactMaxAnswer,
}),
}));
expect(response.status).toBe(200);
const json = await response.json();
expect(json.success).toBe(true);
expect(generateReconstruction).toHaveBeenCalled();
});
it("rejects malformed required field (wrong type) with 400", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: ["not-a-string"],
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/targetNodeId.*string/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("rejects oversized targetDescription with 400", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "x".repeat(2049),
centralStatement: "central statement",
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/targetDescription.*exceeds maximum length/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("rejects malformed centralStatement (number) with 400", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: 12345,
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/centralStatement.*string/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("returns a sanitized 503 when the provider is unavailable", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error(
"Ollama /api/generate request timed out after 5 minutes",
), { code: "PROVIDER_UNAVAILABLE", providerApiPath: "/api/generate" })),
}),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
try {
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(503);
const json = await response.json();
expect(json).toEqual({ error: "Reasoning service is temporarily unavailable." });
expect(JSON.stringify(json)).not.toMatch(/ollama|generate|timed out/i);
} finally {
errorSpy.mockRestore();
}
});
it("preserves the 502 validation-failure contract with diagnostics", async () => {
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
+44
View File
@@ -0,0 +1,44 @@
import React from "react";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { NextRequest } from "next/server";
import PrivacyPage from "@/app/privacy/page.jsx";
import TermsPage from "@/app/terms/page.jsx";
import CookiesPage from "@/app/cookies/page.jsx";
import LegalNavigation from "@/components/legal-navigation.jsx";
import RootLayout from "@/app/layout.jsx";
import { middleware } from "@/middleware.js";
describe("public legal pages", () => {
it.each([
["Privacy Policy", PrivacyPage],
["Terms of Use", TermsPage],
["Cookie Policy", CookiesPage],
])("renders the %s heading", (heading, Page) => {
const html = renderToStaticMarkup(<Page />);
expect(html).toContain(`<h1 class="text-3xl font-bold tracking-tight text-teal-700">${heading}</h1>`);
expect(html).not.toContain('aria-label="Legal information"');
});
it("keeps the root layout as the single legal navigation owner", () => {
const html = renderToStaticMarkup(
<RootLayout>
<PrivacyPage />
</RootLayout>,
);
expect(html.match(/aria-label="Legal information"/g)).toHaveLength(1);
});
it("exposes all public legal links without cookie consent controls", () => {
const html = renderToStaticMarkup(<LegalNavigation />);
expect(html).toContain('href="/privacy"');
expect(html).toContain('href="/terms"');
expect(html).toContain('href="/cookies"');
expect(html).not.toMatch(/Accept cookies|Reject cookies/i);
});
it.each(["privacy", "terms", "cookies"])("keeps /%s public", async (path) => {
const response = await middleware(new NextRequest(`http://localhost:3000/${path}`));
expect(response.status).toBe(200);
});
});
@@ -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");
});
});
+22
View File
@@ -14,7 +14,9 @@ import ReasoningWorkspace, {
} from "@/components/reasoning-workspace.jsx";
import {
ScenarioResultPanels,
UnavailableStartPanel,
UpdateErrorPanel,
isUnavailableStartResponse,
submitAnswerForUpdateCase,
submitScenarioForStartCase,
} from "@/components/scenario-form.jsx";
@@ -372,6 +374,26 @@ function makeCommercialUpdateSuccess(overrides = {}) {
}
describe("scenario-form UI helpers", () => {
it("recognises only the established sanitized unavailable start response", () => {
expect(isUnavailableStartResponse(
{ status: 503 },
{ success: false, error: "Reasoning service is temporarily unavailable." },
)).toBe(true);
expect(isUnavailableStartResponse(
{ status: 500 },
{ success: false, error: "Reasoning service is temporarily unavailable." },
)).toBe(false);
});
it("presents generic recovery without provider details", () => {
const html = renderToStaticMarkup(<UnavailableStartPanel onRetry={vi.fn()} />);
expect(html).toContain("Confidence Engine is temporarily unavailable.");
expect(html).toContain("Your scenario is still here and you can try again.");
expect(html).toContain(">Retry<");
expect(html).not.toMatch(/ollama|provider|local model|server|infrastructure/i);
});
it("submits to /api/cases/start", async () => {
const fetchImpl = vi.fn().mockResolvedValue({ ok: true });