Compare commits

..
37 changed files with 141 additions and 1804 deletions
-3
View File
@@ -46,6 +46,3 @@ tests-results/
# Evidence/temp directories from live experiments
.evidence-temp/
# Jenkins env
deploy.env
+10 -10
View File
@@ -3,27 +3,26 @@ 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}
ENV NEXT_PUBLIC_SUPABASE_URL="" \
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; \
corepack prepare pnpm@latest --activate; \
pnpm install --frozen-lockfile; \
elif [ -f yarn.lock ]; then \
yarn install --frozen-lockfile; \
yarn install --frozen-lockfile; \
else \
npm ci; \
npm ci; \
fi
COPY . .
RUN npm run build
RUN NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} \
next build
# ── Stage 2: Production runtime ────────────────────────────────────────
FROM node:22-alpine AS runner
@@ -35,6 +34,7 @@ ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0"
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
Vendored
-114
View File
@@ -1,114 +0,0 @@
// 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}"
}
}
}
+3 -19
View File
@@ -26,7 +26,7 @@ async function post(request) {
if (!result.success) {
return Response.json(
{ error: "Reasoning request could not be completed." },
{ ...result, reconstruction: result.reconstruction || null },
{ status: Number(result.statusCode) || 500 },
);
}
@@ -42,25 +42,9 @@ 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: "Reasoning request could not be completed." },
{ status: statusCode },
{ error: e.message || "Unknown server error", responseDurationMs: 0 },
{ status: 500 },
);
}
}
+1 -3
View File
@@ -55,9 +55,7 @@ async function post(request) {
{
success: false,
stage: error.statusCode === 400 ? "request_validation" : "provider",
error: error.statusCode === 400
? "Invalid overview request"
: "Reasoning request could not be completed.",
error: error.message ?? "Overview synthesis failed",
},
{ status: error.statusCode }
);
+8 -13
View File
@@ -10,17 +10,6 @@ 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
@@ -48,8 +37,14 @@ async function post(request) {
return Response.json(
{
success: false,
error: status === 400 ? diagnostics.error : "Reasoning request could not be completed.",
...(status === 400 ? { validationErrors: result.validationErrors } : {}),
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,
},
{ status },
);
+1 -3
View File
@@ -57,9 +57,7 @@ async function post(request) {
{
success: false,
stage: error.statusCode === 400 ? "request_validation" : "provider",
error: error.statusCode === 400
? "Invalid synthesis request"
: "Reasoning request could not be completed.",
error: error.message ?? "Synthesis failed",
},
{ status: error.statusCode }
);
@@ -50,23 +50,6 @@ 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,
@@ -161,17 +144,11 @@ async function post(request) {
});
console.info("[api/focused-investigation/deconstruct] end", {
targetNodeId,
status: e?.code === "PROVIDER_UNAVAILABLE" ? 503 : 500,
status: 500,
elapsedMs,
});
if (e?.code === "PROVIDER_UNAVAILABLE") {
return Response.json(
{ error: "Reasoning service is temporarily unavailable." },
{ status: 503 },
);
}
return Response.json(
{ error: "Reasoning request could not be completed." },
{ error: e.message || "Unknown server error" },
{ status: 500 },
);
}
+47 -1
View File
@@ -1,3 +1,49 @@
import { getConfig } from "@/lib/config";
export async function GET() {
return Response.json({ healthy: true }, { status: 200 });
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 }
);
}
}
+1 -16
View File
@@ -4,22 +4,7 @@ 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));
const response = NextResponse.redirect(new URL("/", requestUrl.origin));
if (code) {
const supabase = createServerClient(
-35
View File
@@ -1,35 +0,0 @@
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>
);
}
+2 -7
View File
@@ -1,8 +1,6 @@
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",
@@ -12,7 +10,7 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="flex min-h-screen flex-col bg-gray-50 text-gray-900">
<body className="min-h-screen 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 (_) {}`,
@@ -27,10 +25,7 @@ export default function RootLayout({ children }) {
</div>
</div>
</header>
<div className="flex-1">{children}</div>
<footer className="app-chrome border-t border-gray-200/80 px-6 py-5">
<LegalNavigation />
</footer>
{children}
</body>
</html>
);
+2 -101
View File
@@ -1,104 +1,5 @@
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>
);
}
return <LoginForm />;
}
-47
View File
@@ -1,47 +0,0 @@
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
@@ -1,42 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,18 +0,0 @@
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>
);
}
+4 -11
View File
@@ -25,8 +25,8 @@ export default function LoginForm() {
}
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">
<main className="mx-auto flex min-h-[calc(100vh-57px)] max-w-[640px] items-center px-6 py-16">
<section className="w-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-9 shadow-sm">
<p className="mb-3 text-[11px] font-bold uppercase tracking-[.18em] text-teal-700/70">Welcome</p>
<h1 className="text-3xl font-bold tracking-tight">Confidence Engine</h1>
<p className="mt-3 text-sm leading-relaxed text-gray-600">Enter your email and we will send you a secure link to continue.</p>
@@ -39,14 +39,7 @@ export default function LoginForm() {
</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>
</section>
</main>
);
}
+2 -8
View File
@@ -30,9 +30,6 @@ function isTechnicalSummary(summary) {
return false;
}
// ── Focused answer contract ──────────────────────────────────
const FOCUSED_ANSWER_MAX_LENGTH = 10000;
// ── Recovery state components (Phase 2) ───────────────────────
function ProviderUnavailableCard({ onRestart }) {
@@ -241,11 +238,8 @@ 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} 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>
<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>
</div>
)}
+3 -32
View File
@@ -33,28 +33,6 @@ 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 },
@@ -694,7 +672,8 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
updateStatus === "loading"
);
const handleStart = async () => {
const handleSubmit = async (e) => {
e.preventDefault();
setStatus("loading");
setResult(null);
setAnswer("");
@@ -720,8 +699,6 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
setInvestigationRevision(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);
@@ -733,11 +710,6 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
}
};
const handleSubmit = (e) => {
e.preventDefault();
void handleStart();
};
const handleUpdate = async (e) => {
e.preventDefault();
@@ -827,7 +799,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
return (
<div className="space-y-6">
{/* ── Idle form for scenario input ─ */}
{!result?.situationGraph && (status === "idle" || status === "unavailable") && (
{!result?.situationGraph && status === "idle" && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Two-column landing workspace */}
@@ -892,7 +864,6 @@ 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>
-1
View File
@@ -1,6 +1,5 @@
"use client";
import React from "react";
import { useEffect, useState } from "react";
import {
readThemePreference,
-12
View File
@@ -1,12 +0,0 @@
# 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
+9 -305
View File
@@ -3,181 +3,19 @@
> **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
## v0.62d production Docker packaging established
- `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.
- **Docker image build: BLOCKED** — no Docker runtime on development machine (apparatus limitation, not product defect)
- Production container starts / `/api/health` smoke test: pending Docker runtime availability
- No persistent application volume required
- Supabase remains external, Ollama remains private and server-reachable
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` as established by the implementation (baked into browser bundle)
- **No deployment performed yet**
## CURRENT MVP DIRECTION
@@ -237,46 +75,11 @@ 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/product-platform-foundation-v0.62`
- **HEAD:** *(checkpoint commit — see git log for actual SHA)*
- **Working tree:** clean
## Architectural Conclusion
**The original v0.62 product-platform objective is achieved:**
- 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 following are NOT required and NOT justified before testing:**
- 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.
> **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.**
- **HEAD:** `6dd447e` — feat(confidence-engine): add authenticated investigation persistence
- **Working tree:** dirty with completed v0.62c cutover (server-authoritative persistence, async seam, 404→null correction)
## Persistence
@@ -409,102 +212,3 @@ 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
+7 -123
View File
@@ -1,46 +1,18 @@
# 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
## v0.62d Production Docker Packaging
- `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.
- Docker image build: blocked — no Docker runtime on development machine (apparatus limitation, not product defect)
- Production container start / `/api/health` smoke test: pending Docker availability
- No persistent application volume required
- Supabase remains external; Ollama remains private and server-reachable
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` (baked into browser bundle)
- **No deployment performed**
> 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.
@@ -216,35 +188,6 @@ 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)
@@ -287,65 +230,6 @@ 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):
+1 -3
View File
@@ -100,7 +100,6 @@ export async function analyseScenario(scenario, opts = {}) {
"500",
e.providerApiPath,
e.providerExecution,
e.code,
);
}
@@ -176,7 +175,7 @@ function tryValidateAgainstSchema(data, schema) {
// ── Result builders ──────────────────────────────────
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution, code) {
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution) {
return {
success: false,
error: message,
@@ -188,7 +187,6 @@ function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath,
statusCode,
providerApiPath,
providerExecution,
code,
};
}
-1
View File
@@ -382,7 +382,6 @@ 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,9 +439,6 @@ 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;
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
const PUBLIC_PATHS = ["/login", "/auth", "/api/health", "/privacy", "/terms", "/cookies"];
const PUBLIC_PATHS = ["/login", "/auth"];
export async function middleware(request) {
const pathname = request.nextUrl.pathname;
-189
View File
@@ -1,189 +0,0 @@
#!/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
-205
View File
@@ -1,205 +0,0 @@
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);
});
});
+1 -37
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockSynthesize = vi.fn();
@@ -11,10 +11,6 @@ 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());
@@ -30,36 +26,4 @@ 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);
});
});
+23 -51
View File
@@ -1,22 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockStartCase = vi.fn();
let realStartCase;
let warnSpy;
let errorSpy;
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,
vi.mock("@/lib/graph/orchestrator.js", () => ({
startCase: (...args) => mockStartCase(...args),
}));
describe("app/api/cases/start route", () => {
@@ -134,42 +123,7 @@ describe("app/api/cases/start route", () => {
expect(errorSpy).not.toHaveBeenCalled();
});
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 () => {
it("returns provider/internal failures as 5xx without stack traces", async () => {
const rawResponse = `{"reconstruction":{"observedStates":[{"id":"obs-1"${"x".repeat(2500)}}]}}`;
mockStartCase.mockResolvedValue({
success: false,
@@ -207,8 +161,26 @@ describe("app/api/cases/start route", () => {
expect(response.status).toBe(502);
const body = await response.json();
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(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(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
"[api/cases/start] error response",
@@ -13,10 +13,6 @@ vi.mock("@/lib/llm/provider.js", () => ({
getProviderModelName: () => "gpt-5.6-terra",
}));
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
// ── Helpers ─────────────────────────────────────────────────
function makeValidGraph() {
@@ -114,13 +110,10 @@ describe("POST /api/cases/synthesis — error cases", () => {
});
it("domain seam throws with statusCode → mapped status", async () => {
const err = Object.assign(
new Error("Ollama /api/generate returned 500 from private host"),
{ statusCode: 502 },
);
mockSynthesize.mockImplementationOnce(async () => {
throw err;
});
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 { POST } = await import("@/app/api/cases/synthesis/route.js");
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
@@ -129,17 +122,10 @@ 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.mockImplementationOnce(async () => {
throw new Error("unknown error");
});
mockSynthesize.mockRejectedValue(new Error("unknown error"));
const { POST } = await import("@/app/api/cases/synthesis/route.js");
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
@@ -152,9 +138,7 @@ 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.mockImplementationOnce(async () => {
throw err;
});
mockSynthesize.mockRejectedValue(err);
const { POST } = await import("@/app/api/cases/synthesis/route.js");
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
+5 -61
View File
@@ -19,47 +19,9 @@ vi.mock("@/lib/config", () => ({
}));
vi.mock("@supabase/ssr", () => ({
createServerClient: () => ({
auth: {
getUser: () => mockGetUser(),
exchangeCodeForSession: vi.fn().mockResolvedValue(undefined),
},
}),
createServerClient: () => ({ auth: { getUser: () => mockGetUser() } }),
}));
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();
@@ -96,33 +58,15 @@ describe("authenticated product boundary", () => {
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 () => {
it("keeps infrastructure health public", async () => {
mockGetConfig.mockReturnValue({ ok: false });
const { GET } = await import("@/app/api/health/route.js");
const response = await GET();
expect(response.status).toBe(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);
expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({ configPresent: false });
expect(mockGetAuthenticatedUser).not.toHaveBeenCalled();
});
it("redirects unauthenticated product access to the login surface", async () => {
+2 -204
View File
@@ -9,10 +9,6 @@
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) {
@@ -360,7 +356,7 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
expect(json.providerExecution).toBeUndefined();
});
it("sanitizes generic provider failures while logging structural diagnostics", async () => {
it("preserves the 500 provider-failure contract while logging structural diagnostics", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
@@ -383,7 +379,7 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
}),
}));
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({ error: "Reasoning request could not be completed." });
await expect(response.json()).resolves.toEqual({ error: "provider failed" });
expect(errorSpy).toHaveBeenCalledWith(
"[api/focused-investigation/deconstruct] provider failure",
expect.objectContaining({ targetNodeId: "node-id", providerApiPath: "/v1/responses" }),
@@ -393,204 +389,6 @@ 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
@@ -1,44 +0,0 @@
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);
});
});
-22
View File
@@ -14,9 +14,7 @@ import ReasoningWorkspace, {
} from "@/components/reasoning-workspace.jsx";
import {
ScenarioResultPanels,
UnavailableStartPanel,
UpdateErrorPanel,
isUnavailableStartResponse,
submitAnswerForUpdateCase,
submitScenarioForStartCase,
} from "@/components/scenario-form.jsx";
@@ -374,26 +372,6 @@ 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 });