Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc1cb0fe6 | ||
|
|
2349ef9d59 | ||
|
|
a6796c6f73 | ||
|
|
2cb2d556fd | ||
|
|
b9d49ff277 | ||
|
|
27ded883e5 | ||
|
|
96b47d16a3 | ||
|
|
00d7593ffd | ||
|
|
60c90bdd6a | ||
|
|
707fe1b3c0 | ||
|
|
ed033e71d5 | ||
|
|
6974b710de | ||
|
|
b93aad9667 |
@@ -26,7 +26,7 @@ async function post(request) {
|
||||
|
||||
if (!result.success) {
|
||||
return Response.json(
|
||||
{ ...result, reconstruction: result.reconstruction || null },
|
||||
{ error: "Reasoning request could not be completed." },
|
||||
{ status: Number(result.statusCode) || 500 },
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,25 @@ async function post(request) {
|
||||
promptVersion: result.promptVersion,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.code === "PROVIDER_UNAVAILABLE") {
|
||||
return Response.json(
|
||||
{ error: "Reasoning service is temporarily unavailable." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const validationFailed = e?.validationFailed || e?.code === "VALIDATION_FAILED";
|
||||
if (validationFailed) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Reasoning request could not be completed." },
|
||||
{ status: Number(e.statusCode) || 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const statusCode = Number(e.statusCode) || 500;
|
||||
return Response.json(
|
||||
{ error: e.message || "Unknown server error", responseDurationMs: 0 },
|
||||
{ status: 500 },
|
||||
{ error: "Reasoning request could not be completed." },
|
||||
{ status: statusCode },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ async function post(request) {
|
||||
{
|
||||
success: false,
|
||||
stage: error.statusCode === 400 ? "request_validation" : "provider",
|
||||
error: error.message ?? "Overview synthesis failed",
|
||||
error: error.statusCode === 400
|
||||
? "Invalid overview request"
|
||||
: "Reasoning request could not be completed.",
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
|
||||
@@ -10,6 +10,17 @@ 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
|
||||
@@ -37,14 +48,8 @@ async function post(request) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: diagnostics.error,
|
||||
validationErrors: result.validationErrors,
|
||||
diagnostics: result.diagnostics,
|
||||
analysisErrors: result.analysisErrors,
|
||||
validationIssues: result.validationIssues,
|
||||
providerApiPath: result.providerApiPath,
|
||||
providerExecution: result.providerExecution,
|
||||
rawResponse: result.rawResponse ?? undefined,
|
||||
error: status === 400 ? diagnostics.error : "Reasoning request could not be completed.",
|
||||
...(status === 400 ? { validationErrors: result.validationErrors } : {}),
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
|
||||
@@ -57,7 +57,9 @@ async function post(request) {
|
||||
{
|
||||
success: false,
|
||||
stage: error.statusCode === 400 ? "request_validation" : "provider",
|
||||
error: error.message ?? "Synthesis failed",
|
||||
error: error.statusCode === 400
|
||||
? "Invalid synthesis request"
|
||||
: "Reasoning request could not be completed.",
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
|
||||
@@ -50,6 +50,23 @@ 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,
|
||||
@@ -144,11 +161,17 @@ async function post(request) {
|
||||
});
|
||||
console.info("[api/focused-investigation/deconstruct] end", {
|
||||
targetNodeId,
|
||||
status: 500,
|
||||
status: e?.code === "PROVIDER_UNAVAILABLE" ? 503 : 500,
|
||||
elapsedMs,
|
||||
});
|
||||
if (e?.code === "PROVIDER_UNAVAILABLE") {
|
||||
return Response.json(
|
||||
{ error: "Reasoning service is temporarily unavailable." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return Response.json(
|
||||
{ error: e.message || "Unknown server error" },
|
||||
{ error: "Reasoning request could not be completed." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
+1
-31
@@ -1,33 +1,3 @@
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = getConfig();
|
||||
|
||||
if (!result.ok) {
|
||||
return Response.json({ healthy: false }, { status: 500 });
|
||||
}
|
||||
|
||||
const { OLLAMA_BASE_URL, OLLAMA_MODEL } = result.config;
|
||||
|
||||
// Test reachability with a short timeout
|
||||
let reachable = false;
|
||||
|
||||
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 {
|
||||
reachable = false;
|
||||
}
|
||||
|
||||
return Response.json({ healthy: reachable }, { status: reachable ? 200 : 503 });
|
||||
} catch {
|
||||
return Response.json({ healthy: false }, { status: 500 });
|
||||
}
|
||||
return Response.json({ healthy: true }, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import LegalPageLayout from "@/components/legal-page-layout";
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<LegalPageLayout title="Cookie Policy">
|
||||
<p>This policy explains the browser storage Confidence Engine currently uses. LocalStorage and sessionStorage are browser storage technologies; they are not necessarily HTTP cookies.</p>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Authentication and session cookies</h2>
|
||||
<p>Confidence Engine uses Supabase authentication and session cookies to provide secure sign-in and keep authenticated users signed in. These are essential to the authenticated service. Runtime cookie names and durations are managed by the authentication system.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Theme preference</h2>
|
||||
<p><strong>confidence-engine-theme</strong> is stored in localStorage to remember your light or dark display preference. It remains until you change the preference or clear browser storage.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Introductory guidance preference</h2>
|
||||
<p><strong>ce-facilitator-dismissed</strong> is stored in sessionStorage if you dismiss introductory guidance. It is session-scoped and remembers that choice while the browser session remains available.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">No analytics or advertising tracking</h2>
|
||||
<p>Confidence Engine currently does not use advertising cookies, analytics cookies, tracking pixels, marketing cookies, or third-party browser tracking based on the current implementation.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Future changes</h2>
|
||||
<p>If Confidence Engine later introduces non-essential cookies or similar technologies, this policy and any consent mechanism will be reconsidered as appropriate.</p>
|
||||
</section>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
+7
-2
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import "./globals.css";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
import LogoutButton from "@/components/logout-button";
|
||||
import LegalNavigation from "@/components/legal-navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "Confidence Engine",
|
||||
@@ -10,7 +12,7 @@ export const metadata = {
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<body className="flex min-h-screen flex-col bg-gray-50 text-gray-900">
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try { const saved = localStorage.getItem('confidence-engine-theme'); const theme = saved === 'dark' || saved === 'light' ? saved : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; } catch (_) {}`,
|
||||
@@ -25,7 +27,10 @@ export default function RootLayout({ children }) {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
<div className="flex-1">{children}</div>
|
||||
<footer className="app-chrome border-t border-gray-200/80 px-6 py-5">
|
||||
<LegalNavigation />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+92
-24
@@ -2,35 +2,103 @@ import LoginForm from "@/components/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="mx-auto min-h-[calc(100vh-57px)] max-w-[1200px] px-6 py-16">
|
||||
<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">
|
||||
<section>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-teal-700">Confidence Engine</h1>
|
||||
{/* 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="mt-4 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>
|
||||
<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="md:col-start-2 md:w-full">
|
||||
<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>
|
||||
|
||||
<section className="space-y-4">
|
||||
<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><strong>1. Describe your situation</strong><br/><span className="text-gray-600">As much or as little as you currently know.</span></li>
|
||||
<li><strong>2. Explore what{'\''}s unclear</strong><br/><span className="text-gray-600">Answer the questions that feel useful; skip the ones that don{'\''}t.</span></li>
|
||||
<li><strong>3. Build your Current Understanding</strong><br/><span className="text-gray-600">Your picture of the situation develops as you learn more.</span></li>
|
||||
<li><strong>4. 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></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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import LegalPageLayout from "@/components/legal-page-layout";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPageLayout title="Privacy Policy">
|
||||
<p>This policy explains how Confidence Engine handles information when you use the service.</p>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Who is responsible</h2>
|
||||
<p>Confidence Engine is operated by RDB Solutions Ltd., Palmeira Avenue Mansions, 19 Church Road, Hove, East Sussex, England, BN3 2FA. For privacy questions or deletion requests, contact <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Information we process</h2>
|
||||
<p>We process your account email and authentication information, together with information you choose to enter into an investigation. This can include scenario descriptions, reconstructed SituationGraph material, Current Understanding, Open Questions, answers, Findings, Contributions, reports, and revision and timestamp metadata.</p>
|
||||
<p>Please do not enter personal information that you do not need to provide, especially unnecessary information about other people.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Why we use it</h2>
|
||||
<p>We use account information to provide secure access to your account. We use investigation information to save your work, let you return to it, and provide the reasoning features you request. Confidence Engine supports your understanding; it does not make decisions for you.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Storage and reasoning</h2>
|
||||
<p>Authentication and investigation persistence are operated through our self-hosted Supabase and PostgreSQL infrastructure. Reasoning requests are processed through private Ollama/Qwen infrastructure used by the service. We also use functional browser storage for authentication sessions and interface preferences; see our Cookie Policy for details.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Retention and deletion</h2>
|
||||
<p>Account and investigation information is retained while your account remains active. After 18 months without activity, we will contact you before deletion; if inactivity continues, account and investigation information may then be automatically deleted. You may request deletion at <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>.</p>
|
||||
<p>Deleted information may remain temporarily in rotating backups until those backups expire.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Security and your rights</h2>
|
||||
<p>We use access controls and technical measures appropriate to operating the service. You can contact us about access, correction, deletion, or other data-protection requests. You may also complain to the UK Information Commissioner'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import LegalPageLayout from "@/components/legal-page-layout";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalPageLayout title="Terms of Use">
|
||||
<p>Confidence Engine is operated by RDB Solutions Ltd. These Terms govern your use of the service.</p>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Eligibility and accounts</h2>
|
||||
<p>You must be at least 18 years old to use Confidence Engine. Keep access to your email account and sign-in link secure, and provide accurate information when creating or using an account.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Using the service</h2>
|
||||
<p>Use the service lawfully and responsibly. You are responsible for the information you enter and should avoid entering information about others unless it is necessary and appropriate to do so.</p>
|
||||
<p>Confidence Engine facilitates understanding; it does not make decisions for you. AI or model-generated analysis may be incomplete, inaccurate, or unsuitable for your circumstances. It is not professional, legal, financial, medical, or other regulated advice. You remain responsible for your decisions and actions.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Availability</h2>
|
||||
<p>We aim to keep the service available, but availability may vary. Reasoning functionality may occasionally be temporarily unavailable, and we may change, suspend, or withdraw parts of the service when reasonably necessary.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Your information and intellectual property</h2>
|
||||
<p>You retain responsibility for your underlying scenarios and information. We do not claim ownership of that underlying material merely because you use the service. The Confidence Engine service, branding, and software remain the property of RDB Solutions Ltd. or its licensors.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Ending access and liability</h2>
|
||||
<p>You may stop using the service and request account deletion at <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>. We may suspend or end access where reasonably necessary, including for misuse or security reasons.</p>
|
||||
<p>Nothing in these Terms excludes liability that cannot legally be excluded. Subject to that, the service is provided for a controlled early release and we are not liable for indirect loss or for decisions you make using it.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Changes and law</h2>
|
||||
<p>We may update these Terms by publishing the revised version on this page. These Terms are governed by the law of England and Wales. Questions can be sent to <a className="text-teal-700 underline" href="mailto:data@rdbtech.co.uk">data@rdbtech.co.uk</a>.</p>
|
||||
</section>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LegalNavigation() {
|
||||
return (
|
||||
<nav aria-label="Legal information" className="flex flex-wrap justify-center gap-x-5 gap-y-2 text-sm text-gray-500">
|
||||
<Link href="/privacy" className="hover:text-teal-700">Privacy</Link>
|
||||
<Link href="/terms" className="hover:text-teal-700">Terms</Link>
|
||||
<Link href="/cookies" className="hover:text-teal-700">Cookies</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LegalPageLayout({ title, children }) {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-12 sm:py-16">
|
||||
<Link href="/login" className="text-sm font-medium text-teal-700 hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
<article className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-6 py-8 shadow-sm sm:px-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-teal-700">{title}</h1>
|
||||
<div className="mt-8 space-y-7 text-base leading-relaxed text-gray-700">
|
||||
{children}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,9 @@ function isTechnicalSummary(summary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Focused answer contract ──────────────────────────────────
|
||||
const FOCUSED_ANSWER_MAX_LENGTH = 10000;
|
||||
|
||||
// ── Recovery state components (Phase 2) ───────────────────────
|
||||
|
||||
function ProviderUnavailableCard({ onRestart }) {
|
||||
@@ -238,8 +241,11 @@ function FocusedQuestionBody({
|
||||
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
|
||||
<div data-testid="completed-narrative">
|
||||
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
|
||||
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="mt-3 rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
|
||||
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} maxLength={FOCUSED_ANSWER_MAX_LENGTH} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-400">{focusedAnswer.length}/{FOCUSED_ANSWER_MAX_LENGTH}</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,6 +33,28 @@ export async function submitScenarioForStartCase(fetchImpl, scenario) {
|
||||
});
|
||||
}
|
||||
|
||||
export function isUnavailableStartResponse(response, data) {
|
||||
return response.status === 503 &&
|
||||
data?.success === false &&
|
||||
data?.error === "Reasoning service is temporarily unavailable.";
|
||||
}
|
||||
|
||||
export function UnavailableStartPanel({ onRetry }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<p className="font-medium">Confidence Engine is temporarily unavailable.</p>
|
||||
<p className="mt-1">We couldn'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 },
|
||||
@@ -672,8 +694,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
updateStatus === "loading"
|
||||
);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const handleStart = async () => {
|
||||
setStatus("loading");
|
||||
setResult(null);
|
||||
setAnswer("");
|
||||
@@ -699,6 +720,8 @@ 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);
|
||||
@@ -710,6 +733,11 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
void handleStart();
|
||||
};
|
||||
|
||||
const handleUpdate = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -799,7 +827,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Idle form for scenario input ─ */}
|
||||
{!result?.situationGraph && status === "idle" && (
|
||||
{!result?.situationGraph && (status === "idle" || status === "unavailable") && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{/* Two-column landing workspace */}
|
||||
@@ -864,6 +892,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
<p className="mt-3 text-xs italic text-gray-400">
|
||||
You do not need all the answers yet.
|
||||
</p>
|
||||
{status === "unavailable" && <UnavailableStartPanel onRetry={handleStart} />}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
readThemePreference,
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
> **Role:** Concise operational snapshot for resuming work today. Not a historical diary.
|
||||
> The design evolution archive index at `docs/design-evolution/README.md` provides progressive loading of experiment history; load the relevant chapter only when a specific historical question requires it.
|
||||
|
||||
## Tester legal information
|
||||
|
||||
- Public Privacy, Terms, and Cookie pages and shared legal navigation are available at `/privacy`, `/terms`, and `/cookies`.
|
||||
- The service is positioned as 18+. No cookie-consent banner is used because current browser storage is limited to essential authentication/session storage and functional UI preferences.
|
||||
- Legal wording remains subject to appropriate professional review.
|
||||
- Legal navigation is a single global normal-flow footer: it rests at the viewport bottom on short pages, long legal pages push it naturally below their content, and duplicate legal navigation was removed.
|
||||
|
||||
## Focused-investigation provider outage boundary
|
||||
|
||||
- Focused-investigation outage handling now sanitizes provider failure at the API boundary: unavailable focused reasoning returns a controlled HTTP 503, and raw provider/Ollama diagnostics no longer leave that boundary.
|
||||
- The existing generic Retry UX remains unchanged. A live deployed outage had already proven investigation preservation.
|
||||
- `/api/cases/start` and `/api/cases/update` outage sanitization remain separately unverified; this change does not claim those routes are fixed.
|
||||
|
||||
## Reasoning API browser error boundary hardened
|
||||
|
||||
- All reasoning HTTP error responses now sanitize raw provider diagnostics: `e.message` / raw internals never leak to the client.
|
||||
- Verified on: `/api/cases/start`, `/api/focused-investigation/deconstruct`, `/api/cases/overview`, `/api/cases/synthesis`, `/api/analyse`.
|
||||
- Provider/internal diagnostics remain server-side only.
|
||||
- No reasoning semantics changed.
|
||||
|
||||
## v0.62d production Docker packaging — LIVE PROVEN
|
||||
|
||||
- `Dockerfile` — minimal multi-stage Alpine build (Node 22), Next.js standalone output mode
|
||||
@@ -105,6 +125,60 @@ Jenkins SCM branch used to load the Jenkinsfile is conceptually separate from th
|
||||
- CSS Grid with responsive column placement replaces original flexbox; three DOM sections ensure correct mobile stacking without duplicating content
|
||||
- desktop two-column presentation (explanation left / sign-in right) preserved unchanged at `md` breakpoint and above
|
||||
|
||||
## Focused-investigation input hardening
|
||||
|
||||
- Focused answer now has a visible 10,000-character UI limit (maxLength + character counter).
|
||||
- Server enforces matching 10,000-character bound on `/api/focused-investigation/deconstruct`.
|
||||
- Other prompt-bearing fields retain explicit defensive bounds (2048 characters each).
|
||||
- Oversized/malformed requests are rejected before reasoning with controlled HTTP 400.
|
||||
- No punctuation/HTML/SQL-style content stripping introduced.
|
||||
- Reasoning semantics unchanged.
|
||||
|
||||
## Pre-Tester Input Security Review
|
||||
|
||||
**Status:** closed — sufficient for controlled external-user testing. Not a general security audit, penetration test, or production-launch certification.
|
||||
|
||||
### SQL injection
|
||||
- No raw request-driven SQL construction found.
|
||||
- Persistence uses Supabase/PostgREST query-builder boundaries.
|
||||
- SQL-character stripping / sanitisation is not warranted.
|
||||
|
||||
### XSS
|
||||
- Normal user-controlled text is React-escaped.
|
||||
- No user-controlled unsafe HTML sink found.
|
||||
- HTML / script stripping of prose is not warranted.
|
||||
|
||||
### Reasoning error disclosure
|
||||
- Browser-facing reasoning errors have been sanitised (commit `2cb2d55`).
|
||||
|
||||
### Focused user input
|
||||
- Bounded to 10,000 characters server-side on `/api/focused-investigation/deconstruct`.
|
||||
- UI exposes matching `maxLength` and character counter (commit `a6796c6`).
|
||||
|
||||
### Investigation snapshot size
|
||||
- Authenticated persistence envelope currently lacks a whole-snapshot size ceiling.
|
||||
- Not classified as an outstanding pre-tester blocker.
|
||||
- Legitimate investigation size / turn depth is not yet known.
|
||||
- No arbitrary product ceiling imposed before real-user evidence exists.
|
||||
- Monitor snapshot growth later via metadata (serialized bytes / revision / contribution count) without logging investigation content.
|
||||
- Malformed-envelope / runtime validation remains a separate future hardening opportunity.
|
||||
|
||||
### Prompt injection
|
||||
- Assessed as **low risk under the current architecture** — not claimed to be impossible or "solved".
|
||||
- Untrusted scenario / answer / persisted text can influence model reasoning.
|
||||
- No evidence it gains application authority: no model-accessible arbitrary tools, DB targeting, auth identity control, ownership bypass, deletion, or arbitrary external requests found.
|
||||
- Model/provider configuration is server-owned; model output passes through parsing/structured validation/domain boundaries before application mutation.
|
||||
- No prompt-injection phrase / keyword filtering warranted; do not strip instruction-like natural-language content.
|
||||
- Not a pre-tester blocker.
|
||||
|
||||
### Deferred non-security observations (backlog only — no code change)
|
||||
- Orchestrator update flow contains an implicitly correct but indent-control-flow-unclear fall-through / else structure worth cleaning up later.
|
||||
- Investigation overview validation may accept unexpected extra fields.
|
||||
- Provider JSON recovery is intentionally/permissively capable of recovering malformed JSON; may merit a future robustness review.
|
||||
|
||||
### Tester-readiness position
|
||||
Identified MEDIUM pre-tester security work is sufficiently addressed for controlled external-user testing. The application has **not** been generally security-audited, penetration-tested, or certified as production-secure or commercially launch-ready.
|
||||
|
||||
## CURRENT MVP DIRECTION
|
||||
|
||||
Initial-decomposition hardening is frozen for the current MVP stage.
|
||||
@@ -335,3 +409,102 @@ Previous focused-deconstruction semantic runs before the plumbing fix remain **i
|
||||
2. Initial-reconstruction schema was supplied to focused-deconstruction call instead of its own contract
|
||||
|
||||
These are recorded as known contamination in the v0.61 archive chapter (`docs/design-evolution/ch19/initial-decomposition-v0.61.md`). The plumbing fix is complete and verified (48/48 tests).
|
||||
|
||||
## Tester-Readiness Findings — Recorded for Session Continuity
|
||||
|
||||
### Tester-readiness position
|
||||
|
||||
```
|
||||
controlled external testing: GO
|
||||
|
||||
known pre-tester security blockers: none identified
|
||||
|
||||
next uncertainty worth reducing: real first-time-user product value, trust and independent usability
|
||||
```
|
||||
|
||||
Do not claim commercial launch readiness, general production scalability, security certification or proven product-market fit.
|
||||
|
||||
### First external-user experiment
|
||||
|
||||
**Primary question:** Can a first-time user, without Rob guiding the investigation, use Confidence Engine to reach a Current Understanding that they regard as materially better than the way they framed the situation at the start?
|
||||
|
||||
**Tester evidence should prioritise:**
|
||||
- did understanding materially improve?
|
||||
- did they trust the changed understanding?
|
||||
- could they reach it without Rob's help?
|
||||
- was the experience usable?
|
||||
|
||||
**Post-use discovery questions (do not seed categories or mention a mental-health interpretation):**
|
||||
- Who do you think this would be useful for?
|
||||
- What kinds of situations would you use this for?
|
||||
- Is there a situation in your own life or work where you can imagine coming back and using this?
|
||||
|
||||
### Landing-page positioning
|
||||
|
||||
- Current landing wording is deliberately unchanged for the first cohort.
|
||||
- It may naturally be interpreted broadly, potentially including mental-health/anxiety-adjacent situations.
|
||||
- This is an observation to learn from, not currently a defect.
|
||||
- Use unprompted tester responses to learn what category/audience/use cases users believe Confidence Engine belongs to.
|
||||
- Do not reposition before this evidence exists.
|
||||
|
||||
### Synthetic-user testing
|
||||
|
||||
- AI-driven first-time-user journeys may be useful as a pre-human stress test.
|
||||
- Preferred conceptual separation: **synthetic first-time user → real Confidence Engine journey → independent evaluator**.
|
||||
- Synthetic testing can expose reasoning/UX/systematic failures.
|
||||
- It does NOT replace human evidence about genuine trust, changed understanding, usefulness, repeat use or willingness to return.
|
||||
- No synthetic-testing implementation is currently required before human testing.
|
||||
|
||||
### Reasoning concurrency
|
||||
|
||||
- Current CE application has **no** server-side per-user reasoning concurrency protection.
|
||||
- No CE-level global reasoning concurrency limit.
|
||||
- Multiple tabs/users can reach provider concurrently.
|
||||
- Ollama/provider concurrency and queue behaviour are not controlled by CE repository code.
|
||||
- Do not invent queue/latency behaviour from the 300-second request timeout.
|
||||
- Controlled cohort of roughly 5–10 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
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Current Project State — Confidence Engine
|
||||
|
||||
## Tester Legal Information
|
||||
|
||||
- Public Privacy, Terms, and Cookie pages and shared legal navigation are available at `/privacy`, `/terms`, and `/cookies`.
|
||||
- The service is positioned as 18+. No cookie-consent banner is used because current browser storage is limited to essential authentication/session storage and functional UI preferences.
|
||||
- Legal wording remains subject to appropriate professional review.
|
||||
|
||||
## Focused-Investigation Provider Outage Boundary
|
||||
|
||||
- Focused-investigation outage handling now sanitizes provider failure at the API boundary: unavailable focused reasoning returns a controlled HTTP 503, and raw provider/Ollama diagnostics no longer leave that boundary.
|
||||
- The existing generic Retry UX remains unchanged. A live deployed outage had already proven investigation preservation.
|
||||
- `/api/cases/start` and `/api/cases/update` outage sanitization remain separately unverified; this change does not claim those routes are fixed.
|
||||
|
||||
## v0.62d Production Docker Packaging — LIVE PROVEN
|
||||
|
||||
- `Dockerfile` created: multi-stage Alpine build (Node 22), Next.js standalone output, configurable port 3000, health-check boundary via `/api/health`
|
||||
@@ -275,6 +287,65 @@ The following material learnings are carried forward as durable context for safe
|
||||
User-selected/active investigation ownership must survive substantive ties and
|
||||
question-formulation rejection. (Already documented in `docs/current-handoff.md`.)
|
||||
|
||||
## 11. Pre-Tester Input Security Review (closed)
|
||||
|
||||
**Status:** closed — sufficient for controlled external-user testing. Not a general security audit, penetration test, or production-launch certification.
|
||||
|
||||
### SQL injection
|
||||
- No raw request-driven SQL construction found; persistence uses Supabase/PostgREST query-builder boundaries.
|
||||
|
||||
### XSS
|
||||
- Normal user-controlled text is React-escaped; no unsafe HTML sink found.
|
||||
|
||||
### Reasoning error disclosure
|
||||
- Browser-facing reasoning errors sanitised (commit `2cb2d55`).
|
||||
|
||||
### Focused input bound
|
||||
- 10,000-character server-side + UI boundary on focused investigation (commit `a6796c6`).
|
||||
|
||||
### Investigation snapshot size envelope / investigation growth
|
||||
- Authenticated persistence lacks a whole-snapshot size ceiling.
|
||||
- No arbitrary whole-investigation snapshot ceiling before real-user evidence.
|
||||
- Legitimate turn depth and mature investigation size are unknown.
|
||||
- Later observation may use serialized snapshot bytes, revision, contribution count and finding count without recording content.
|
||||
- Not a pre-tester blocker.
|
||||
|
||||
### Prompt injection
|
||||
- **Low risk under the current architecture.** Untrusted text can influence model reasoning but no evidence it gains application authority. No model-accessible arbitrary tools, DB targeting, auth control, or privileged side effects found. Output passes structured validation before application mutation. Not a pre-tester blocker.
|
||||
|
||||
### Deferred non-security observations (backlog only — no code change)
|
||||
- Orchestrator update flow indentation/control-flow clarity deferred.
|
||||
- Investigation overview validation may accept unexpected extra fields.
|
||||
- Provider JSON recovery permissiveness deferred as future robustness review.
|
||||
- Malformed persistence-envelope/runtime validation deferred.
|
||||
- Initial Analyse duplicate-submit prevention deferred (action not disabled by `status === "loading"`).
|
||||
|
||||
### Tester-readiness position
|
||||
|
||||
```
|
||||
controlled external testing: GO
|
||||
|
||||
known pre-tester security blockers: none identified
|
||||
|
||||
next uncertainty worth reducing: real first-time-user product value, trust and independent usability
|
||||
```
|
||||
|
||||
Do not claim commercial launch readiness, general production scalability, security certification or proven product-market fit.
|
||||
|
||||
### Reasoning concurrency (deferred)
|
||||
- No server-side per-user reasoning concurrency protection; no CE-level global limit.
|
||||
- Multiple tabs/users can reach provider concurrently — Ollama/provider behaviour not controlled by CE repository code.
|
||||
- Controlled cohort of ~5–10 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 14–17
|
||||
|
||||
Since the handoff document was written, further learning has emerged from Return-to-Origin work (RTO.14–17):
|
||||
|
||||
+3
-1
@@ -100,6 +100,7 @@ export async function analyseScenario(scenario, opts = {}) {
|
||||
"500",
|
||||
e.providerApiPath,
|
||||
e.providerExecution,
|
||||
e.code,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +176,7 @@ function tryValidateAgainstSchema(data, schema) {
|
||||
|
||||
// ── Result builders ──────────────────────────────────
|
||||
|
||||
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution) {
|
||||
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution, code) {
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
@@ -187,6 +188,7 @@ function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath,
|
||||
statusCode,
|
||||
providerApiPath,
|
||||
providerExecution,
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,7 @@ export async function startCase(body, dependencies = {}) {
|
||||
providerApiPath: analysis.providerApiPath ?? undefined,
|
||||
providerExecution: analysis.providerExecution ?? undefined,
|
||||
rawResponse: analysis.rawResponse ?? undefined,
|
||||
code: analysis.code ?? undefined,
|
||||
statusCode: Number(analysis.statusCode) || 502,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -439,6 +439,9 @@ class OllamaLlmProvider {
|
||||
`- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` +
|
||||
`- Check Ollama logs: \`ollama serve\` or look at your system logs`
|
||||
);
|
||||
if (e?.name === "AbortError" || e instanceof TypeError) {
|
||||
error.code = "PROVIDER_UNAVAILABLE";
|
||||
}
|
||||
error.providerApiPath = apiUsed;
|
||||
error.providerExecution = providerExecution;
|
||||
throw error;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PUBLIC_PATHS = ["/login", "/auth", "/api/health"];
|
||||
const PUBLIC_PATHS = ["/login", "/auth", "/api/health", "/privacy", "/terms", "/cookies"];
|
||||
|
||||
export async function middleware(request) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mock domain seam and provider at module level ────
|
||||
|
||||
const mockAnalyseScenario = vi.fn();
|
||||
|
||||
vi.mock("@/lib/analysis", () => ({
|
||||
analyseScenario: (...args) => mockAnalyseScenario(...args),
|
||||
PROMPT_VERSIONS: ["v1"],
|
||||
DEFAULT_PROMPT_VERSION: "v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/llm/provider.js", () => ({
|
||||
getProvider: () => ({}),
|
||||
getProviderModelName: () => "gpt-5.6-terra",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
||||
withAuthenticatedApi: (handler) => handler,
|
||||
}));
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────
|
||||
|
||||
function makeValidScenario() {
|
||||
return "The supplier changed delivery schedules without notice, causing our production line to halt.";
|
||||
}
|
||||
|
||||
function makeRequest(body) {
|
||||
return new Request("http://localhost/api/analyse", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Route tests — valid POST ────────────────────────
|
||||
|
||||
describe("POST /api/analyse — success contract", () => {
|
||||
it("returns structured analysis on success", async () => {
|
||||
mockAnalyseScenario.mockResolvedValue({
|
||||
success: true,
|
||||
inputClassification: "manufacturing",
|
||||
reconstruction: { summary: "Validated reconstruction summary" },
|
||||
evidence: [],
|
||||
nextQuestion: null,
|
||||
modelName: "gpt-5.6-terra",
|
||||
responseDurationMs: 1200,
|
||||
validationStatus: "passed",
|
||||
promptVersion: "v1",
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data.inputClassification).toBe("string");
|
||||
expect(data.reconstruction.summary).toBe("Validated reconstruction summary");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route tests — request validation ────────────────
|
||||
|
||||
describe("POST /api/analyse — request validation", () => {
|
||||
it("missing scenario → 400", async () => {
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({}));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data).toEqual({ error: "Request must include a 'scenario' string field" });
|
||||
});
|
||||
|
||||
it("null scenario → 400", async () => {
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: null }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("non-string scenario → 400", async () => {
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: 123 }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route tests — error handling ────────────────────
|
||||
|
||||
describe("POST /api/analyse — error boundary", () => {
|
||||
it("domain seam throws PROVIDER_UNAVAILABLE → sanitized 503 with generic message", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("Ollama /api/generate request timed out after 5 minutes"),
|
||||
{ code: "PROVIDER_UNAVAILABLE", providerApiPath: "/api/generate" },
|
||||
);
|
||||
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Reasoning service is temporarily unavailable.");
|
||||
});
|
||||
|
||||
it("domain seam throws with diagnostics → sanitized response, preserved status", async () => {
|
||||
const rawResponse = `{"reconstruction":{"observedStates":[{"id":"obs-1"${"x".repeat(2500)}}]}}`;
|
||||
const err = Object.assign(
|
||||
new Error("Provider unavailable"),
|
||||
{
|
||||
statusCode: 502,
|
||||
providerApiPath: "/v1/responses",
|
||||
providerExecution: { chatRequestAttempted: true },
|
||||
rawResponse,
|
||||
},
|
||||
);
|
||||
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Reasoning request could not be completed.");
|
||||
expect(JSON.stringify(data)).not.toMatch(/provider unavailable|generate|llama3|rawResponse/i);
|
||||
});
|
||||
|
||||
it("domain seam throws without statusCode → 500", async () => {
|
||||
mockAnalyseScenario.mockImplementationOnce(async () => { throw new Error("unknown error"); });
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Reasoning request could not be completed.");
|
||||
});
|
||||
|
||||
it("domain seam throws validation failure → preserved status", async () => {
|
||||
const err = Object.assign(new Error("Invalid input"), { statusCode: 400 });
|
||||
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Reasoning request could not be completed.");
|
||||
});
|
||||
|
||||
it("domain seam returns failure result with diagnostics → sanitized response", async () => {
|
||||
mockAnalyseScenario.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Provider unavailable",
|
||||
diagnostics: { modelName: "llama3" },
|
||||
statusCode: 502,
|
||||
analysisErrors: ["reconstruction: Required"],
|
||||
validationIssues: [{ path: ["reconstruction"], code: "invalid_type", message: "Required" }],
|
||||
providerApiPath: "/api/generate",
|
||||
providerExecution: { generateRequestAttempted: true },
|
||||
rawResponse: '{"observedStates":[{"id":"x"}]}',
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
const data = await res.json();
|
||||
expect(data).toEqual({ error: "Reasoning request could not be completed." });
|
||||
expect(JSON.stringify(data)).not.toMatch(/provider unavailable|generate|llama3|rawResponse/i);
|
||||
});
|
||||
|
||||
it("domain seam throws without exposing raw provider/internal diagnostics", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("Internal connection reset by peer — host=10.0.0.5:8080 key=sk-abc"),
|
||||
{ statusCode: 502, providerApiPath: "/internal/chat", providerExecution: { chatRequestAttempted: true } },
|
||||
);
|
||||
mockAnalyseScenario.mockImplementationOnce(async () => { throw err; });
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
const data = await res.json();
|
||||
expect(JSON.stringify(data)).not.toMatch(/10\.0\.0\.5|8080|sk-abc|providerApiPath|providerExecution|Internal connection reset/i);
|
||||
});
|
||||
|
||||
it("domain seam returns failed analysis object → not spread into response", async () => {
|
||||
mockAnalyseScenario.mockResolvedValue({
|
||||
success: false,
|
||||
error: "Analysis failed",
|
||||
statusCode: 500,
|
||||
failedAnalysisObject: { raw: true, internal: "diagnostics" },
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/analyse/route.js");
|
||||
const res = await POST(makeRequest({ scenario: makeValidScenario() }));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const data = await res.json();
|
||||
expect(data).toEqual({ error: "Reasoning request could not be completed." });
|
||||
expect(JSON.stringify(data)).not.toMatch(/failedAnalysisObject|internal|diagnostics/i);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockSynthesize = vi.fn();
|
||||
|
||||
@@ -11,6 +11,10 @@ vi.mock("@/lib/llm/provider.js", () => ({
|
||||
getProviderModelName: () => "gpt-5.6-terra",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
||||
withAuthenticatedApi: (handler) => handler,
|
||||
}));
|
||||
|
||||
describe("POST /api/cases/overview provider routing", () => {
|
||||
beforeEach(() => mockSynthesize.mockClear());
|
||||
|
||||
@@ -26,4 +30,36 @@ describe("POST /api/cases/overview provider routing", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSynthesize.mock.calls[0][1]).toMatchObject({ modelName: "gpt-5.6-terra" });
|
||||
});
|
||||
|
||||
it("sanitizes provider failure details", async () => {
|
||||
const error = Object.assign(
|
||||
new Error("Ollama /api/generate returned 500 from private host"),
|
||||
{ statusCode: 502 },
|
||||
);
|
||||
mockSynthesize.mockImplementationOnce(async () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/cases/overview/route.js");
|
||||
const response = await POST(new Request("http://localhost/api/cases/overview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph: { nodes: [], edges: [] } }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const rawData = await response.text();
|
||||
const data = JSON.parse(rawData);
|
||||
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.stage).toBe("provider");
|
||||
expect(data).toEqual({
|
||||
success: false,
|
||||
stage: "provider",
|
||||
error: "Reasoning request could not be completed.",
|
||||
});
|
||||
|
||||
// Prove raw provider diagnostics are NOT exposed at the route boundary
|
||||
expect(rawData).not.toContain(error.message);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,22 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockStartCase = vi.fn();
|
||||
let realStartCase;
|
||||
let warnSpy;
|
||||
let errorSpy;
|
||||
|
||||
vi.mock("@/lib/graph/orchestrator.js", () => ({
|
||||
startCase: (...args) => mockStartCase(...args),
|
||||
vi.mock("@/lib/config.js", () => ({
|
||||
getConfig: () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/graph/orchestrator.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
realStartCase = actual.startCase;
|
||||
return { ...actual, startCase: (...args) => mockStartCase(...args) };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
||||
withAuthenticatedApi: (handler) => handler,
|
||||
}));
|
||||
|
||||
describe("app/api/cases/start route", () => {
|
||||
@@ -123,7 +134,42 @@ describe("app/api/cases/start route", () => {
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns provider/internal failures as 5xx without stack traces", async () => {
|
||||
it("returns a sanitized 503 when the provider is unavailable", async () => {
|
||||
const unavailableError = Object.assign(
|
||||
new Error("Ollama /api/generate request timed out after 5 minutes"),
|
||||
{
|
||||
code: "PROVIDER_UNAVAILABLE",
|
||||
providerApiPath: "/api/generate",
|
||||
providerExecution: { generateRequestAttempted: true },
|
||||
},
|
||||
);
|
||||
const reconstructionProvider = {
|
||||
generateReconstruction: vi.fn().mockRejectedValue(unavailableError),
|
||||
};
|
||||
mockStartCase.mockImplementation((body) => realStartCase(body, {
|
||||
reconstructionProvider,
|
||||
reconstructionModelName: "configured-model",
|
||||
}));
|
||||
|
||||
const { POST } = await import("@/app/api/cases/start/route.js");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/cases/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ scenario: "Scenario text" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: "Reasoning service is temporarily unavailable.",
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toMatch(/ollama|generate|timed out/i);
|
||||
});
|
||||
|
||||
it("sanitizes provider/internal failures at the browser boundary", async () => {
|
||||
const rawResponse = `{"reconstruction":{"observedStates":[{"id":"obs-1"${"x".repeat(2500)}}]}}`;
|
||||
mockStartCase.mockResolvedValue({
|
||||
success: false,
|
||||
@@ -161,26 +207,8 @@ describe("app/api/cases/start route", () => {
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty("rawResponse");
|
||||
expect(body.rawResponse).toBe(rawResponse);
|
||||
expect(body.rawResponse.length).toBeGreaterThan(2000);
|
||||
expect(body.analysisErrors).toEqual(["reconstruction: Required"]);
|
||||
expect(body.validationIssues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["reconstruction", "observedStates", 2, "description"],
|
||||
code: "invalid_type",
|
||||
message: "Required",
|
||||
expected: "string",
|
||||
received: "undefined",
|
||||
}),
|
||||
]);
|
||||
expect(body.providerApiPath).toBe("/api/generate");
|
||||
expect(body.providerExecution).toEqual({
|
||||
chatCapabilityDetected: false,
|
||||
chatRequestAttempted: false,
|
||||
chatRequestSucceeded: false,
|
||||
generateRequestAttempted: true,
|
||||
});
|
||||
expect(body).toEqual({ success: false, error: "Reasoning request could not be completed." });
|
||||
expect(JSON.stringify(body)).not.toMatch(/provider unavailable|generate|llama3|rawResponse/i);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"[api/cases/start] error response",
|
||||
|
||||
@@ -13,6 +13,10 @@ vi.mock("@/lib/llm/provider.js", () => ({
|
||||
getProviderModelName: () => "gpt-5.6-terra",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
||||
withAuthenticatedApi: (handler) => handler,
|
||||
}));
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function makeValidGraph() {
|
||||
@@ -110,10 +114,13 @@ describe("POST /api/cases/synthesis — error cases", () => {
|
||||
});
|
||||
|
||||
it("domain seam throws with statusCode → mapped status", async () => {
|
||||
mockSynthesize.mockRejectedValue(new Error("Provider failed"));
|
||||
// Add statusCode property to the error object after creation
|
||||
const err = Object.assign(new Error("Provider failed"), { statusCode: 502 });
|
||||
mockSynthesize.mockRejectedValue(err);
|
||||
const err = Object.assign(
|
||||
new Error("Ollama /api/generate returned 500 from private host"),
|
||||
{ statusCode: 502 },
|
||||
);
|
||||
mockSynthesize.mockImplementationOnce(async () => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
@@ -122,10 +129,17 @@ describe("POST /api/cases/synthesis — error cases", () => {
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.stage).toBe("provider");
|
||||
expect(data).toEqual({
|
||||
success: false,
|
||||
stage: "provider",
|
||||
error: "Reasoning request could not be completed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("domain seam throws without statusCode → 500", async () => {
|
||||
mockSynthesize.mockRejectedValue(new Error("unknown error"));
|
||||
mockSynthesize.mockImplementationOnce(async () => {
|
||||
throw new Error("unknown error");
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
@@ -138,7 +152,9 @@ describe("POST /api/cases/synthesis — error cases", () => {
|
||||
|
||||
it("domain seam throws 400 → mapped to 400", async () => {
|
||||
const err = Object.assign(new Error("Invalid input"), { statusCode: 400 });
|
||||
mockSynthesize.mockRejectedValue(err);
|
||||
mockSynthesize.mockImplementationOnce(async () => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
||||
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
||||
|
||||
@@ -103,23 +103,18 @@ describe("authenticated product boundary", () => {
|
||||
const response = await GET();
|
||||
|
||||
expect(mockGetAuthenticatedUser).not.toHaveBeenCalled();
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty("healthy");
|
||||
// Must not expose private config details in the public response
|
||||
expect(JSON.stringify(body)).not.toContain("baseUrl");
|
||||
expect(JSON.stringify(body)).not.toContain("model");
|
||||
expect(JSON.stringify(body)).not.toContain("ollama");
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ healthy: true });
|
||||
});
|
||||
|
||||
it("reports unhealthy generic state when config is missing", async () => {
|
||||
it("remains healthy when reasoning configuration is missing", async () => {
|
||||
mockGetConfig.mockReturnValue({ ok: false });
|
||||
const { GET } = await import("@/app/api/health/route.js");
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ healthy: false });
|
||||
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 () => {
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation";
|
||||
|
||||
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
||||
withAuthenticatedApi: (handler) => handler,
|
||||
}));
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMockProvider(inventedTargetNodeId) {
|
||||
@@ -356,7 +360,7 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
|
||||
expect(json.providerExecution).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the 500 provider-failure contract while logging structural diagnostics", async () => {
|
||||
it("sanitizes generic provider failures while logging structural diagnostics", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({
|
||||
@@ -379,7 +383,7 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
|
||||
}),
|
||||
}));
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.json()).resolves.toEqual({ error: "provider failed" });
|
||||
await expect(response.json()).resolves.toEqual({ error: "Reasoning request could not be completed." });
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"[api/focused-investigation/deconstruct] provider failure",
|
||||
expect.objectContaining({ targetNodeId: "node-id", providerApiPath: "/v1/responses" }),
|
||||
@@ -389,6 +393,204 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized focused answer with 400 and does not reach reasoning seam", async () => {
|
||||
const generateReconstruction = vi.fn().mockResolvedValue({
|
||||
response: {},
|
||||
providerApiPath: "/api/chat",
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({ generateReconstruction }),
|
||||
getProviderModelName: () => "configured-model",
|
||||
}));
|
||||
|
||||
const largeAnswer = "x".repeat(10001);
|
||||
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id",
|
||||
targetLabel: "label",
|
||||
targetDescription: "description",
|
||||
centralStatement: "central statement",
|
||||
question: "question?",
|
||||
answer: largeAnswer,
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toMatch(/answer.*exceeds maximum length/i);
|
||||
expect(generateReconstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts focused answer at exact server max (10000) and reaches reasoning seam", async () => {
|
||||
const generateReconstruction = vi.fn().mockResolvedValue({
|
||||
response: {
|
||||
targetNodeId: "node-id",
|
||||
observations: [],
|
||||
uncertainties: [],
|
||||
assumptions: [],
|
||||
relationships: [],
|
||||
possibleFollowUpQuestions: [],
|
||||
},
|
||||
providerApiPath: "/api/chat",
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({ generateReconstruction }),
|
||||
getProviderModelName: () => "configured-model",
|
||||
}));
|
||||
|
||||
const exactMaxAnswer = "x".repeat(10000);
|
||||
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id",
|
||||
targetLabel: "label",
|
||||
targetDescription: "description",
|
||||
centralStatement: "central statement",
|
||||
question: "question?",
|
||||
answer: exactMaxAnswer,
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.success).toBe(true);
|
||||
expect(generateReconstruction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed required field (wrong type) with 400", async () => {
|
||||
const generateReconstruction = vi.fn().mockResolvedValue({
|
||||
response: {}, providerApiPath: "/api/chat",
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({ generateReconstruction }),
|
||||
getProviderModelName: () => "configured-model",
|
||||
}));
|
||||
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: ["not-a-string"],
|
||||
targetLabel: "label",
|
||||
targetDescription: "description",
|
||||
centralStatement: "central statement",
|
||||
question: "question?",
|
||||
answer: "answer.",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toMatch(/targetNodeId.*string/i);
|
||||
expect(generateReconstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversized targetDescription with 400", async () => {
|
||||
const generateReconstruction = vi.fn().mockResolvedValue({
|
||||
response: {}, providerApiPath: "/api/chat",
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({ generateReconstruction }),
|
||||
getProviderModelName: () => "configured-model",
|
||||
}));
|
||||
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id",
|
||||
targetLabel: "label",
|
||||
targetDescription: "x".repeat(2049),
|
||||
centralStatement: "central statement",
|
||||
question: "question?",
|
||||
answer: "answer.",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toMatch(/targetDescription.*exceeds maximum length/i);
|
||||
expect(generateReconstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed centralStatement (number) with 400", async () => {
|
||||
const generateReconstruction = vi.fn().mockResolvedValue({
|
||||
response: {}, providerApiPath: "/api/chat",
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({ generateReconstruction }),
|
||||
getProviderModelName: () => "configured-model",
|
||||
}));
|
||||
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id",
|
||||
targetLabel: "label",
|
||||
targetDescription: "description",
|
||||
centralStatement: 12345,
|
||||
question: "question?",
|
||||
answer: "answer.",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toMatch(/centralStatement.*string/i);
|
||||
expect(generateReconstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a sanitized 503 when the provider is unavailable", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({
|
||||
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error(
|
||||
"Ollama /api/generate request timed out after 5 minutes",
|
||||
), { code: "PROVIDER_UNAVAILABLE", providerApiPath: "/api/generate" })),
|
||||
}),
|
||||
getProviderModelName: () => "configured-model",
|
||||
}));
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
try {
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
|
||||
centralStatement: "central", question: "question?", answer: "answer.",
|
||||
}),
|
||||
}));
|
||||
expect(response.status).toBe(503);
|
||||
const json = await response.json();
|
||||
expect(json).toEqual({ error: "Reasoning service is temporarily unavailable." });
|
||||
expect(JSON.stringify(json)).not.toMatch(/ollama|generate|timed out/i);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the 502 validation-failure contract with diagnostics", async () => {
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import PrivacyPage from "@/app/privacy/page.jsx";
|
||||
import TermsPage from "@/app/terms/page.jsx";
|
||||
import CookiesPage from "@/app/cookies/page.jsx";
|
||||
import LegalNavigation from "@/components/legal-navigation.jsx";
|
||||
import RootLayout from "@/app/layout.jsx";
|
||||
import { middleware } from "@/middleware.js";
|
||||
|
||||
describe("public legal pages", () => {
|
||||
it.each([
|
||||
["Privacy Policy", PrivacyPage],
|
||||
["Terms of Use", TermsPage],
|
||||
["Cookie Policy", CookiesPage],
|
||||
])("renders the %s heading", (heading, Page) => {
|
||||
const html = renderToStaticMarkup(<Page />);
|
||||
expect(html).toContain(`<h1 class="text-3xl font-bold tracking-tight text-teal-700">${heading}</h1>`);
|
||||
expect(html).not.toContain('aria-label="Legal information"');
|
||||
});
|
||||
|
||||
it("keeps the root layout as the single legal navigation owner", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RootLayout>
|
||||
<PrivacyPage />
|
||||
</RootLayout>,
|
||||
);
|
||||
expect(html.match(/aria-label="Legal information"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exposes all public legal links without cookie consent controls", () => {
|
||||
const html = renderToStaticMarkup(<LegalNavigation />);
|
||||
expect(html).toContain('href="/privacy"');
|
||||
expect(html).toContain('href="/terms"');
|
||||
expect(html).toContain('href="/cookies"');
|
||||
expect(html).not.toMatch(/Accept cookies|Reject cookies/i);
|
||||
});
|
||||
|
||||
it.each(["privacy", "terms", "cookies"])("keeps /%s public", async (path) => {
|
||||
const response = await middleware(new NextRequest(`http://localhost:3000/${path}`));
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,9 @@ import ReasoningWorkspace, {
|
||||
} from "@/components/reasoning-workspace.jsx";
|
||||
import {
|
||||
ScenarioResultPanels,
|
||||
UnavailableStartPanel,
|
||||
UpdateErrorPanel,
|
||||
isUnavailableStartResponse,
|
||||
submitAnswerForUpdateCase,
|
||||
submitScenarioForStartCase,
|
||||
} from "@/components/scenario-form.jsx";
|
||||
@@ -372,6 +374,26 @@ function makeCommercialUpdateSuccess(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("scenario-form UI helpers", () => {
|
||||
it("recognises only the established sanitized unavailable start response", () => {
|
||||
expect(isUnavailableStartResponse(
|
||||
{ status: 503 },
|
||||
{ success: false, error: "Reasoning service is temporarily unavailable." },
|
||||
)).toBe(true);
|
||||
expect(isUnavailableStartResponse(
|
||||
{ status: 500 },
|
||||
{ success: false, error: "Reasoning service is temporarily unavailable." },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("presents generic recovery without provider details", () => {
|
||||
const html = renderToStaticMarkup(<UnavailableStartPanel onRetry={vi.fn()} />);
|
||||
|
||||
expect(html).toContain("Confidence Engine is temporarily unavailable.");
|
||||
expect(html).toContain("Your scenario is still here and you can try again.");
|
||||
expect(html).toContain(">Retry<");
|
||||
expect(html).not.toMatch(/ollama|provider|local model|server|infrastructure/i);
|
||||
});
|
||||
|
||||
it("submits to /api/cases/start", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user