diff --git a/admin-console/src/components/layout.tsx b/admin-console/src/components/layout.tsx
index adcc90c..a22fd67 100644
--- a/admin-console/src/components/layout.tsx
+++ b/admin-console/src/components/layout.tsx
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -57,14 +58,16 @@ export default function Layout() {
}
/>
- Switch organization
-
- {me?.memberships.map((m) => (
- navigate(`/organizations/${m.organization.id}/users`)}>
- {m.organization.name}
- {m.organization.id === organizationId && current}
-
- ))}
+
+ Switch organization
+
+ {me?.memberships.map((m) => (
+ navigate(`/organizations/${m.organization.id}/users`)}>
+ {m.organization.name}
+ {m.organization.id === organizationId && current}
+
+ ))}
+
navigate("/")}>All organizations…
diff --git a/compose.dev.yml b/compose.dev.yml
index 7d68ad8..49305f3 100644
--- a/compose.dev.yml
+++ b/compose.dev.yml
@@ -108,7 +108,7 @@ services:
keycloak:
condition: service_started
healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+ test: ["CMD", "node", "-e", "require('node:http').get('http://127.0.0.1:4000/health',r=>{r.resume();process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
interval: 5s
timeout: 4s
retries: 30
@@ -145,4 +145,3 @@ volumes:
redis-dev-data:
keycloak-dev-data:
imaging-dev-data:
-
diff --git a/docs/CLINICAL_WORKSPACE.md b/docs/CLINICAL_WORKSPACE.md
index 294f8cf..9799f08 100644
--- a/docs/CLINICAL_WORKSPACE.md
+++ b/docs/CLINICAL_WORKSPACE.md
@@ -602,15 +602,15 @@ Notably:
tested in this codebase (see [Development: testing](DEVELOPMENT.md#testing)).
Playwright e2e (`e2e/tests/*.spec.ts`) has not been extended for the clinical
-layer with scripted assertions, but `e2e/full-app-tour.js` (a headless,
-non-mocked Electron launch — the same `_electron.launch({headless: "new"})`
-pattern as `manual-app-test.js`) has been run against it end to end: every
-clinical route (Patient Cases list, create, detail; Evidence Library;
-Knowledge Graph; Audit & Privacy) plus every Settings and Runtime Manager tab,
-with zero console errors or uncaught page errors captured across the whole
-run. Screenshots from that pass are in `docs/screenshots/`. This is visual/
-crash verification, not scripted behavioral assertions — see [Known
-limitations](#known-limitations) for what that still leaves uncovered.
+layer with scripted assertions, but a one-off manual tour script (a headless,
+non-mocked Electron launch via `_electron.launch({headless: "new"})`, not
+kept in the repo) has been run against it end to end: every clinical route
+(Patient Cases list, create, detail; Evidence Library; Knowledge Graph; Audit
+& Privacy) plus every Settings and Runtime Manager tab, with zero console
+errors or uncaught page errors captured across the whole run. Screenshots
+from that pass are in `docs/screenshots/`. This is visual/crash verification,
+not scripted behavioral assertions — see [Known limitations](#known-limitations)
+for what that still leaves uncovered.
## Known limitations
diff --git a/docs/RELEASE_VALIDATION_2026-08-31.md b/docs/RELEASE_VALIDATION_2026-08-31.md
new file mode 100644
index 0000000..16f7a25
--- /dev/null
+++ b/docs/RELEASE_VALIDATION_2026-08-31.md
@@ -0,0 +1,98 @@
+# ModelForge v1.3.0 release validation
+
+Date: 2026-08-31
+
+## Result
+
+The installed Windows release and the repository build passed the automated,
+integration, container, and visual checks listed below. The validation recording
+contains synthetic, PHI-free data and captures only the ModelForge application
+window.
+
+[Watch the compact release proof](assets/modelforge-v1.3.0-release-proof.mp4)
+
+## Installed release walkthrough
+
+The 3-minute, 655 KB H.264 recording demonstrates:
+
+- local-only chat and the new-chat workflow;
+- Runtime Manager detection and live telemetry for an NVIDIA RTX 4060;
+- balanced, performance, efficient, and manual resource profiles;
+- Download Center limits and concurrent-download controls;
+- audit, privacy, encryption, and audit-backend status;
+- a synthetic patient case and its inclusion controls;
+- Evidence Library and Knowledge Graph navigation.
+
+No AWS credential, AWS account identifier, prompt, patient identifier, or other
+secret is visible in the recording or this report.
+
+## Automated evidence
+
+| Area | Result |
+| --- | --- |
+| Desktop application unit tests | 1,018 passed, 1 skipped across 78 files |
+| Server default test mode | 650 passed, 84 skipped across 55 files |
+| Server with isolated PostgreSQL and Redis | 731 passed, 3 opt-in live-adapter tests skipped across 55 files |
+| Frontend tests | 101 passed |
+| Admin console tests | 61 passed |
+| MasterVault tests | 14 passed |
+| Rust native tests | 80 passed |
+| Playwright desktop E2E | 25 passed |
+| Imaging CDK regression tests | 3 passed |
+
+The Playwright suite covers approvals, audit persistence and backend migration,
+backup/restore, signed central policy handling, cancellation, Download Center,
+llama.cpp chat and agent tool gating, medication safety, onboarding, patient-case
+backend configuration, response contracts, settings persistence, and
+single-instance behavior.
+
+Production builds/typechecks passed for the desktop application, server,
+frontend, admin console, shared contracts, MasterVault, Rust native component,
+E2E package, and imaging CDK. Frontend and admin lint also passed.
+
+The development container stack was rebuilt and verified healthy. PostgreSQL,
+Redis, the API, Keycloak, and the admin console all started successfully; the API
+health endpoint returned `{"status":"ok"}` and the admin console returned HTTP
+200.
+
+## Defects corrected during validation
+
+1. The server container could load a stale copied `@modelforge/contracts`
+ package and fail during startup. The image now links the workspace contracts
+ package after dependency installation.
+2. The API health check did not reliably consume the response or terminate from
+ the HTTP status. It now exits deterministically after consuming the response.
+3. The PostgreSQL migration-audit test ordered same-timestamp events only by
+ `created_at`. It now asserts the audit ledger's monotonic numeric sequence.
+4. The imaging CDK stack contained a redundant exact-distribution KMS grant that
+ created a CloudFormation dependency cycle. The stack now relies on the CDK
+ OAC-generated, account-scoped distribution grant.
+5. The dedicated CloudFront access-log bucket used bucket-owner-enforced object
+ ownership, which is incompatible with CloudFront legacy standard logging. It
+ now enables the required object-writer ownership while retaining public-access
+ blocking. The PHI imaging bucket remains bucket-owner-enforced.
+6. CloudFormation descriptions used unsupported punctuation. They now use the
+ supported ASCII character set.
+
+Regression tests protect the CloudFront log-bucket ownership, KMS grant shape,
+and CloudFormation description character set.
+
+## External deployment status
+
+The imaging stack synthesized successfully after the fixes. Deployment reached
+AWS and exposed the CloudFront logging and dependency-cycle defects above, but a
+subsequent deploy is blocked before resource mutation because the account's CDK
+bootstrap toolkit is version 28 and the current deployment requires version 30.
+Upgrading the bootstrap stack changes account-level deployment IAM and therefore
+requires explicit operator approval. No credential value was read or printed.
+
+Production Compose configuration additionally requires deployment-specific
+`OIDC_ISSUER` configuration; no value was invented for validation.
+
+## Scope boundary
+
+This run verifies all repository-owned automated suites and the installed
+workflows listed above. Provider-dependent live imaging adapters, real clinical
+data flows, real external model/provider credentials, and the final AWS resource
+deployment require their respective controlled environments and are not claimed
+as exercised here.
diff --git a/docs/assets/modelforge-v1.3.0-release-proof.mp4 b/docs/assets/modelforge-v1.3.0-release-proof.mp4
new file mode 100644
index 0000000..690f9b6
Binary files /dev/null and b/docs/assets/modelforge-v1.3.0-release-proof.mp4 differ
diff --git a/e2e/full-app-tour.js b/e2e/full-app-tour.js
deleted file mode 100644
index fe594cf..0000000
--- a/e2e/full-app-tour.js
+++ /dev/null
@@ -1,180 +0,0 @@
-const { _electron: electron } = require("playwright");
-const path = require("path");
-const fs = require("fs");
-const os = require("os");
-const { spawn } = require("child_process");
-const net = require("net");
-
-const APP_DIR = path.resolve(__dirname, "../app");
-const FRONTEND_DIR = path.resolve(__dirname, "../frontend");
-const MAIN_JS = path.join(APP_DIR, "dist", "main.js");
-const MODELS_DIR = "/tmp/modelforge-tour-models";
-const SCREENSHOT_DIR = "/tmp/modelforge-full-tour-screenshots";
-fs.rmSync(SCREENSHOT_DIR, { recursive: true, force: true });
-fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
-
-const results = [];
-function report(name, ok, detail) {
- results.push({ name, ok, detail });
- console.log(`[${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
-}
-
-function waitForPort(port, timeoutMs) {
- const deadline = Date.now() + timeoutMs;
- return new Promise((resolve, reject) => {
- (function attempt() {
- const socket = net.createConnection(port, "127.0.0.1");
- socket.on("connect", () => { socket.end(); resolve(); });
- socket.on("error", () => {
- socket.destroy();
- if (Date.now() > deadline) reject(new Error(`port ${port} never opened`));
- else setTimeout(attempt, 300);
- });
- })();
- });
-}
-
-async function shot(window, name) {
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, `${name}.png`) });
-}
-
-async function main() {
- console.log("=== Starting vite preview server (frontend/dist on :5173) ===");
- const preview = spawn("npx", ["vite", "preview", "--port", "5173", "--strictPort"], {
- cwd: FRONTEND_DIR,
- stdio: ["ignore", "pipe", "pipe"],
- shell: true,
- });
- preview.stdout.on("data", (d) => process.stdout.write(`[vite] ${d}`));
- preview.stderr.on("data", (d) => process.stderr.write(`[vite:err] ${d}`));
- await waitForPort(5173, 20000);
-
- const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-tour-userdata-"));
- const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-tour-workspace-"));
- fs.writeFileSync(
- path.join(userDataDir, "settings.json"),
- JSON.stringify({ onboardingComplete: true, llamaCppModelsDir: MODELS_DIR }, null, 2)
- );
-
- console.log("=== Launching Electron headless ===");
- const app = await electron.launch({
- executablePath: require(path.join(APP_DIR, "node_modules", "electron")),
- args: ["--headless=new", `--user-data-dir=${userDataDir}`, MAIN_JS],
- cwd: APP_DIR,
- env: { ...process.env, DISABLE_GPU: "1" },
- timeout: 30000,
- });
-
- const consoleErrors = [];
- const pageErrors = [];
- const window = await app.firstWindow();
- await window.setViewportSize({ width: 1440, height: 900 });
- window.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
- window.on("pageerror", (e) => pageErrors.push(e.message));
-
- await window.waitForLoadState("domcontentloaded", { timeout: 15000 });
- await window.waitForTimeout(1200);
-
- async function goto(hash, label) {
- try {
- await window.evaluate((h) => { window.location.hash = h; }, hash);
- await window.waitForTimeout(700);
- await shot(window, label);
- report(`Navigated to ${hash}`, true);
- } catch (err) {
- report(`Navigated to ${hash}`, false, err.message);
- }
- }
-
- // --- Chat (home) ---
- await goto("#/", "01-chat");
-
- // --- Patient Cases: list (empty), create, detail ---
- await goto("#/cases", "02-patient-cases-list");
- try {
- const input = window.getByPlaceholder('e.g. "Synthetic case — chest pain workup"');
- await input.fill("Synthetic screenshot-tour case");
- await window.getByRole("button", { name: /new case/i }).click({ timeout: 5000 });
- await window.waitForTimeout(800);
- report("Created a patient case via UI", true);
- await shot(window, "03-patient-cases-list-with-case");
- await window.getByText("Synthetic screenshot-tour case").click({ timeout: 5000 });
- await window.waitForTimeout(800);
- await shot(window, "04-patient-case-detail");
- report("Opened patient case detail view", true);
- } catch (err) {
- report("Create + open a patient case", false, err.message);
- await shot(window, "04-patient-case-detail-FAIL");
- }
-
- // --- Evidence Library, Knowledge Graph, Audit & Privacy ---
- await goto("#/evidence", "05-evidence-library");
- await goto("#/knowledge-graph", "06-knowledge-graph");
- await goto("#/audit", "07-audit-privacy");
-
- // --- Compare, Usage, Downloads ---
- await goto("#/compare", "08-compare-models");
- await goto("#/usage", "09-usage-dashboard");
- await goto("#/downloads", "10-download-center");
-
- // --- Runtime Manager + all its tabs ---
- await goto("#/runtimes", "11-runtime-manager-overview");
- const runtimeTabs = ["Runtimes", "Workloads", "Models", "Environments", "Resource settings", "Fleet", "Logs & diagnostics"];
- for (const [i, tabName] of runtimeTabs.entries()) {
- try {
- await window.getByRole("tab", { name: tabName }).click({ timeout: 5000 });
- await window.waitForTimeout(600);
- await shot(window, `12-${(i + 1).toString().padStart(2, "0")}-runtime-manager-${tabName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`);
- report(`Runtime Manager tab "${tabName}" opens without crashing`, true);
- } catch (err) {
- report(`Runtime Manager tab "${tabName}" opens without crashing`, false, err.message);
- }
- }
-
- // --- Settings + all its tabs ---
- await goto("#/settings", "13-settings-general");
- const settingsTabs = [
- ["General", "general"],
- ["Models", "models"],
- ["Accounts", "accounts"],
- ["Integrations", "integrations"],
- ["Agent & Tools", "chat"],
- ["Voice", "voice"],
- ["Automation", "automation"],
- ["Usage & Diagnostics", "data"],
- ];
- for (const [i, [tabLabel, tabKey]] of settingsTabs.entries()) {
- try {
- await window.getByRole("tab", { name: new RegExp(`^${tabLabel}`, "i") }).click({ timeout: 5000 });
- await window.waitForTimeout(600);
- await shot(window, `14-${(i + 1).toString().padStart(2, "0")}-settings-${tabKey}`);
- report(`Settings tab "${tabLabel}" opens without crashing`, true);
- } catch (err) {
- report(`Settings tab "${tabLabel}" opens without crashing`, false, err.message);
- }
- }
-
- console.log("\n=== Console errors captured during run ===");
- if (consoleErrors.length === 0) console.log("(none)");
- else consoleErrors.forEach((e) => console.log("[console.error] " + e));
-
- console.log("\n=== Uncaught page errors captured during run ===");
- if (pageErrors.length === 0) console.log("(none)");
- else pageErrors.forEach((e) => console.log("[pageerror] " + e));
-
- await app.close();
- preview.kill();
- fs.rmSync(userDataDir, { recursive: true, force: true });
- fs.rmSync(workspaceDir, { recursive: true, force: true });
-
- console.log("\n=== SUMMARY ===");
- for (const r of results) console.log(`${r.ok ? "PASS" : "FAIL"} — ${r.name}${r.detail ? " (" + r.detail + ")" : ""}`);
- const failed = results.filter((r) => !r.ok).length;
- console.log(`\nScreenshots written to ${SCREENSHOT_DIR}`);
- process.exit(failed > 0 ? 1 : 0);
-}
-
-main().catch((err) => {
- console.error("FATAL:", err);
- process.exit(1);
-});
diff --git a/e2e/headless-probe.js b/e2e/headless-probe.js
deleted file mode 100644
index 04ec413..0000000
--- a/e2e/headless-probe.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const { _electron: electron } = require("playwright");
-const path = require("path");
-const fs = require("fs");
-const os = require("os");
-
-(async () => {
- const APP_DIR = path.resolve(__dirname, "../app");
- const MAIN_JS = path.join(APP_DIR, "dist", "main.js");
- const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-headless-probe-"));
- try {
- const app = await electron.launch({
- executablePath: require(path.join(APP_DIR, "node_modules", "electron")),
- args: [
- "--headless=new",
- "--disable-gpu",
- `--user-data-dir=${userDataDir}`,
- MAIN_JS,
- ],
- cwd: APP_DIR,
- env: { ...process.env, DISABLE_GPU: "1" },
- timeout: 20000,
- });
- console.log("LAUNCH_SUCCESS");
- const window = await app.firstWindow();
- window.on("console", (m) => console.log("[console]", m.type(), m.text()));
- window.on("pageerror", (e) => console.log("[pageerror]", e.message));
- console.log("waiting for load state...");
- await window.waitForLoadState("domcontentloaded", { timeout: 15000 });
- console.log("URL:", window.url());
- await window.waitForTimeout(2000);
- const text = await window.evaluate(() => document.body ? document.body.innerText.slice(0, 300) : "NO_BODY");
- console.log("BODY_TEXT:", JSON.stringify(text));
- const html = await window.evaluate(() => document.documentElement.outerHTML.slice(0, 500));
- console.log("HTML_HEAD:", html);
- await app.close();
- } catch (err) {
- console.log("LAUNCH_FAILED:", err && err.stack ? err.stack : err);
- process.exitCode = 1;
- } finally {
- fs.rmSync(userDataDir, { recursive: true, force: true });
- }
-})();
diff --git a/e2e/manual-app-test-2.js b/e2e/manual-app-test-2.js
deleted file mode 100644
index 3dd5eba..0000000
--- a/e2e/manual-app-test-2.js
+++ /dev/null
@@ -1,198 +0,0 @@
-const { _electron: electron } = require("playwright");
-const path = require("path");
-const fs = require("fs");
-const os = require("os");
-const { spawn } = require("child_process");
-const net = require("net");
-
-const APP_DIR = path.resolve(__dirname, "../app");
-const FRONTEND_DIR = path.resolve(__dirname, "../frontend");
-const MAIN_JS = path.join(APP_DIR, "dist", "main.js");
-const MODELS_DIR = "/tmp/modelforge-manual-test-models";
-const SCREENSHOT_DIR = "/tmp/modelforge-manual-test-screenshots";
-const WORKSPACE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-manual-test-workspace2-"));
-fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
-
-const results = [];
-function report(name, ok, detail) {
- results.push({ name, ok, detail });
- console.log(`[${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
-}
-
-function waitForPort(port, timeoutMs) {
- const deadline = Date.now() + timeoutMs;
- return new Promise((resolve, reject) => {
- (function attempt() {
- const socket = net.createConnection(port, "127.0.0.1");
- socket.on("connect", () => { socket.end(); resolve(); });
- socket.on("error", () => {
- socket.destroy();
- if (Date.now() > deadline) reject(new Error(`port ${port} never opened`));
- else setTimeout(attempt, 300);
- });
- })();
- });
-}
-
-async function killPort(port) {
- return new Promise((resolve) => {
- const p = spawn("bash", ["-lc", `fuser -k ${port}/tcp || true`]);
- p.on("close", () => resolve());
- });
-}
-
-async function main() {
- await killPort(5173);
- await new Promise((r) => setTimeout(r, 500));
-
- console.log("=== Starting vite preview server (frontend/dist on :5173) ===");
- const preview = spawn("npx", ["vite", "preview", "--port", "5173", "--strictPort"], {
- cwd: FRONTEND_DIR,
- stdio: ["ignore", "pipe", "pipe"],
- shell: true,
- });
- preview.stdout.on("data", (d) => process.stdout.write(`[vite] ${d}`));
- preview.stderr.on("data", (d) => process.stderr.write(`[vite:err] ${d}`));
- await waitForPort(5173, 20000);
- report("vite preview server started", true);
-
- const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-manual-test2-userdata-"));
- fs.writeFileSync(
- path.join(userDataDir, "settings.json"),
- JSON.stringify(
- {
- onboardingComplete: true,
- llamaCppModelsDir: MODELS_DIR,
- defaultModel: "llamacpp:qwen2.5-1.5b-instruct-q4_k_m.gguf",
- },
- null,
- 2
- )
- );
-
- console.log("=== Launching Electron headless (Qwen2.5-1.5B) ===");
- const app = await electron.launch({
- executablePath: require(path.join(APP_DIR, "node_modules", "electron")),
- args: ["--headless=new", `--user-data-dir=${userDataDir}`, MAIN_JS],
- cwd: APP_DIR,
- env: { ...process.env, DISABLE_GPU: "1" },
- timeout: 30000,
- });
- report("Electron process launched", true);
-
- const consoleErrors = [];
- const pageErrors = [];
- const window = await app.firstWindow();
- window.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
- window.on("pageerror", (e) => pageErrors.push(e.message));
-
- await window.waitForLoadState("domcontentloaded", { timeout: 15000 });
- await window.waitForTimeout(1500);
-
- // Stub the native folder picker up front for Agent mode's workspace prompt.
- await app.evaluate(({ dialog }, dir) => {
- dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [dir] }));
- }, WORKSPACE_DIR);
-
- // --- Agent mode + real tool-calling with Qwen ---
- try {
- await window.getByRole("button", { name: "Agent", exact: true }).click({ timeout: 5000 });
- await window.waitForTimeout(500);
- const pressed = await window.getByRole("button", { name: /Agent/ }).getAttribute("aria-pressed");
- report("Agent mode enabled", pressed === "true");
-
- const input = window.getByPlaceholder("Send a message...");
- await input.fill("Run the shell command: echo hello-from-real-model");
- const sendButton = window.getByRole("button", { name: "Send message" });
- await sendButton.waitFor({ state: "visible", timeout: 5000 });
- await window.waitForTimeout(2000); // let model load settle
- await sendButton.click({ timeout: 20000 });
- report("Sent a tool-use prompt to the real Qwen2.5-1.5B model", true);
-
- const allowSeen = await window.getByRole("button", { name: "Allow" }).waitFor({ state: "visible", timeout: 45000 }).then(() => true).catch(() => false);
- report("Model produced a real tool call, Allow/Deny approval card appeared", allowSeen);
-
- if (allowSeen) {
- await window.getByRole("button", { name: "Allow" }).click();
- const ranForReal = await window.locator("pre", { hasText: "hello-from-real-model" }).waitFor({ timeout: 20000 }).then(() => true).catch(() => false);
- report("Approved tool call actually executed (real command output visible)", ranForReal);
- }
- } catch (err) {
- report("Agent mode / tool-calling flow", false, err.message);
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "10-agent-toolcall.png") });
-
- // --- Sweep every Settings tab, watching for crashes ---
- const tabs = [
- "General",
- "Models & Hardware",
- "Accounts",
- "Integrations & MCP",
- "Agent & Tools",
- "Voice",
- "Automation",
- "Usage & Diagnostics",
- ];
- try {
- await window.getByRole("button", { name: "Settings" }).click({ timeout: 5000 });
- await window.waitForTimeout(500);
- } catch (err) {
- report("Open Settings", false, err.message);
- }
- for (const tabName of tabs) {
- try {
- await window.getByRole("tab", { name: tabName }).click({ timeout: 5000 });
- await window.waitForTimeout(400);
- report(`Settings tab "${tabName}" opens without crashing`, true);
- } catch (err) {
- report(`Settings tab "${tabName}" opens without crashing`, false, err.message);
- }
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "11-usage-diagnostics.png") });
-
- // --- Diagnostics copy button (touches the diagnostics shape I edited) ---
- try {
- await window.getByRole("button", { name: /Copy diagnostic info/i }).click({ timeout: 5000 });
- report("Copy diagnostic info button works without crashing", true);
- } catch (err) {
- report("Copy diagnostic info button works without crashing", false, err.message);
- }
-
- // --- RAG embedding dropdown actually lists the local GGUF models ---
- try {
- await window.getByRole("tab", { name: "General" }).click({ timeout: 5000 });
- await window.waitForTimeout(400);
- await window.getByRole("combobox", { name: /RAG embedding model/i }).click({ timeout: 5000 }).catch(async () => {
- await window.getByText("RAG embedding model", { exact: true }).locator("..").locator("button, [role=combobox]").first().click({ timeout: 5000 });
- });
- await window.waitForTimeout(400);
- const optionsText = await window.locator('[role="listbox"], [role="option"]').first().locator("..").innerText().catch(() => "");
- const listedModel = /qwen2\.5-1\.5b|SmolLM2/i.test(optionsText);
- report("RAG embedding dropdown lists local llama.cpp GGUF models", listedModel, optionsText.slice(0, 200));
- } catch (err) {
- report("RAG embedding dropdown lists local llama.cpp GGUF models", false, err.message);
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "12-rag-dropdown.png") });
-
- console.log("\n=== Console errors captured during run ===");
- if (consoleErrors.length === 0) console.log("(none)");
- else consoleErrors.forEach((e) => console.log("[console.error] " + e));
-
- console.log("\n=== Uncaught page errors captured during run ===");
- if (pageErrors.length === 0) console.log("(none)");
- else pageErrors.forEach((e) => console.log("[pageerror] " + e));
-
- await app.close();
- preview.kill();
- fs.rmSync(userDataDir, { recursive: true, force: true });
-
- console.log("\n=== SUMMARY ===");
- for (const r of results) console.log(`${r.ok ? "PASS" : "FAIL"} — ${r.name}${r.detail ? " (" + r.detail + ")" : ""}`);
- const failed = results.filter((r) => !r.ok).length;
- process.exit(failed > 0 ? 1 : 0);
-}
-
-main().catch((err) => {
- console.error("FATAL:", err);
- process.exit(1);
-});
diff --git a/e2e/manual-app-test.js b/e2e/manual-app-test.js
deleted file mode 100644
index 2fb9b39..0000000
--- a/e2e/manual-app-test.js
+++ /dev/null
@@ -1,228 +0,0 @@
-const { _electron: electron } = require("playwright");
-const path = require("path");
-const fs = require("fs");
-const os = require("os");
-const { spawn } = require("child_process");
-const net = require("net");
-
-const APP_DIR = path.resolve(__dirname, "../app");
-const FRONTEND_DIR = path.resolve(__dirname, "../frontend");
-const MAIN_JS = path.join(APP_DIR, "dist", "main.js");
-const MODELS_DIR = "/tmp/modelforge-manual-test-models";
-const SCREENSHOT_DIR = "/tmp/modelforge-manual-test-screenshots";
-const WORKSPACE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-manual-test-workspace-"));
-fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
-
-const results = [];
-function report(name, ok, detail) {
- results.push({ name, ok, detail });
- console.log(`[${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
-}
-
-function waitForPort(port, timeoutMs) {
- const deadline = Date.now() + timeoutMs;
- return new Promise((resolve, reject) => {
- (function attempt() {
- const socket = net.createConnection(port, "127.0.0.1");
- socket.on("connect", () => { socket.end(); resolve(); });
- socket.on("error", () => {
- socket.destroy();
- if (Date.now() > deadline) reject(new Error(`port ${port} never opened`));
- else setTimeout(attempt, 300);
- });
- })();
- });
-}
-
-async function main() {
- console.log("=== Starting vite preview server (frontend/dist on :5173) ===");
- const preview = spawn("npx", ["vite", "preview", "--port", "5173", "--strictPort"], {
- cwd: FRONTEND_DIR,
- stdio: ["ignore", "pipe", "pipe"],
- shell: true,
- });
- preview.stdout.on("data", (d) => process.stdout.write(`[vite] ${d}`));
- preview.stderr.on("data", (d) => process.stderr.write(`[vite:err] ${d}`));
- await waitForPort(5173, 20000);
- report("vite preview server started", true);
-
- const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "modelforge-manual-test-userdata-"));
- fs.writeFileSync(
- path.join(userDataDir, "settings.json"),
- JSON.stringify(
- {
- onboardingComplete: true,
- llamaCppModelsDir: MODELS_DIR,
- defaultModel: "llamacpp:SmolLM2-135M-Instruct-Q4_K_M.gguf",
- },
- null,
- 2
- )
- );
-
- console.log("=== Launching Electron headless ===");
- let app;
- try {
- app = await electron.launch({
- executablePath: require(path.join(APP_DIR, "node_modules", "electron")),
- args: ["--headless=new", `--user-data-dir=${userDataDir}`, MAIN_JS],
- cwd: APP_DIR,
- env: { ...process.env, DISABLE_GPU: "1" },
- timeout: 30000,
- });
- report("Electron process launched (headless, no display server)", true);
- } catch (err) {
- report("Electron process launched", false, err.message);
- process.exit(1);
- }
-
- const consoleErrors = [];
- const pageErrors = [];
-
- const window = await app.firstWindow();
- window.on("console", (m) => {
- if (m.type() === "error") consoleErrors.push(m.text());
- });
- window.on("pageerror", (e) => pageErrors.push(e.message));
-
- try {
- await window.waitForLoadState("domcontentloaded", { timeout: 15000 });
- report("Renderer loaded (domcontentloaded)", true);
- } catch (err) {
- report("Renderer loaded (domcontentloaded)", false, err.message);
- }
-
- await window.waitForTimeout(1500);
-
- // Step: chat page basic render
- try {
- const input = window.getByPlaceholder("Send a message...");
- await input.waitFor({ state: "visible", timeout: 15000 });
- report("Chat page rendered with message input", true);
- } catch (err) {
- report("Chat page rendered with message input", false, err.message);
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "01-chat-page-fail.png") });
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "01-chat-page.png") });
-
- // Step: model picker shows the llama.cpp model as selected / available
- try {
- const modelLabel = await window.locator("body").innerText();
- const mentionsModel = /SmolLM2/i.test(modelLabel);
- report("Default llama.cpp model appears selected in UI", mentionsModel, mentionsModel ? undefined : "model name not found anywhere on page");
- } catch (err) {
- report("Default llama.cpp model appears selected in UI", false, err.message);
- }
-
- // Step: send a real chat message and wait for a real streamed response
- try {
- const input = window.getByPlaceholder("Send a message...");
- await input.fill("Say the word BANANA and nothing else.");
- const sendButton = window.getByRole("button", { name: "Send message" });
- await sendButton.waitFor({ state: "visible", timeout: 5000 });
- const enabled = await sendButton.isEnabled({ timeout: 20000 }).catch(() => false);
- if (!enabled) {
- // give it more time; model loading can take a moment
- await window.waitForTimeout(3000);
- }
- await sendButton.click();
- report("Sent a real chat message to the real SmolLM2 model", true);
-
- const chatArea = window.getByRole("main");
- await chatArea.getByText("Say the word BANANA and nothing else.").waitFor({ timeout: 10000 });
- report("User message appears in chat", true);
-
- // Wait for streaming to finish: Send button reappears / Stop button disappears
- await window.getByRole("button", { name: "Stop generating" }).waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
- await window.getByRole("button", { name: "Stop generating" }).waitFor({ state: "hidden", timeout: 60000 }).catch(() => {});
- await window.waitForTimeout(500);
-
- const bodyText = await chatArea.innerText();
- report("Real model produced a response", bodyText.trim().length > 0, `assistant area text length=${bodyText.length}`);
- console.log("--- Chat area text snapshot ---\n" + bodyText.slice(0, 1000) + "\n--- end snapshot ---");
- } catch (err) {
- report("Send/receive a real chat message", false, err.message);
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "02-after-chat.png") });
-
- // Step: create a new chat session
- try {
- await window.getByRole("button", { name: "New chat" }).first().click({ timeout: 5000 });
- await window.waitForTimeout(500);
- const input = window.getByPlaceholder("Send a message...");
- await input.waitFor({ state: "visible", timeout: 5000 });
- report("Created a new chat session", true);
- } catch (err) {
- report("Created a new chat session", false, err.message);
- }
-
- // Step: navigate to Settings
- try {
- await window.getByRole("button", { name: "Settings" }).click({ timeout: 5000 });
- await window.waitForTimeout(1000);
- report("Navigated to Settings page", true);
- } catch (err) {
- report("Navigated to Settings page", false, err.message);
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "03-settings.png") });
-
- // Step: Models tab shows the real downloaded model
- try {
- await window.getByRole("tab", { name: /Models/i }).click({ timeout: 5000 });
- await window.waitForTimeout(500);
- const modelsTabText = await window.locator("body").innerText();
- const found = /SmolLM2/i.test(modelsTabText);
- report("Models tab lists the real downloaded GGUF model", found);
- } catch (err) {
- report("Models tab lists the real downloaded GGUF model", false, err.message);
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "04-settings-models.png") });
-
- // Step: RAG embedding model dropdown offers the llama.cpp model
- try {
- const ragSection = window.locator("text=RAG embedding model");
- await ragSection.scrollIntoViewIfNeeded({ timeout: 5000 });
- report("RAG embedding model section renders without crashing", true);
- } catch (err) {
- report("RAG embedding model section renders without crashing", false, err.message);
- }
-
- // Step: back to Chat via "New chat", then toggle Agent mode (needs a
- // workspace folder — stub the native picker like the real e2e fixtures do)
- try {
- await app.evaluate(({ dialog }, dir) => {
- dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [dir] }));
- }, WORKSPACE_DIR);
- await window.getByRole("button", { name: "New chat" }).first().click({ timeout: 5000 });
- await window.waitForTimeout(500);
- await window.getByRole("button", { name: "Agent", exact: true }).click({ timeout: 5000 });
- await window.waitForTimeout(500);
- const pressed = await window.getByRole("button", { name: /Agent/ }).getAttribute("aria-pressed");
- report("Agent mode toggled on", pressed === "true");
- } catch (err) {
- report("Agent mode toggled on", false, err.message);
- }
- await window.screenshot({ path: path.join(SCREENSHOT_DIR, "05-agent-mode.png") });
-
- console.log("\n=== Console errors captured during run ===");
- if (consoleErrors.length === 0) console.log("(none)");
- else consoleErrors.forEach((e) => console.log("[console.error] " + e));
-
- console.log("\n=== Uncaught page errors captured during run ===");
- if (pageErrors.length === 0) console.log("(none)");
- else pageErrors.forEach((e) => console.log("[pageerror] " + e));
-
- await app.close();
- preview.kill();
- fs.rmSync(userDataDir, { recursive: true, force: true });
-
- console.log("\n=== SUMMARY ===");
- for (const r of results) console.log(`${r.ok ? "PASS" : "FAIL"} — ${r.name}${r.detail ? " (" + r.detail + ")" : ""}`);
- const failed = results.filter((r) => !r.ok).length;
- process.exit(failed > 0 ? 1 : 0);
-}
-
-main().catch((err) => {
- console.error("FATAL:", err);
- process.exit(1);
-});
diff --git a/infra/imaging-cdk/lib/imaging-stack.ts b/infra/imaging-cdk/lib/imaging-stack.ts
index dbe3b8e..2214c18 100644
--- a/infra/imaging-cdk/lib/imaging-stack.ts
+++ b/infra/imaging-cdk/lib/imaging-stack.ts
@@ -43,7 +43,7 @@ export class ImagingStack extends Stack {
// once the bucket and distribution exist, scoped exactly to the
// two principals the table calls for — nothing broader.
const cmk = new kms.Key(this, "ImagingCmk", {
- description: "CMK for ModelForge imaging object storage (DICOM instances) — see docs/IMAGING.md",
+ description: "CMK for ModelForge imaging object storage (DICOM instances) - see docs/IMAGING.md",
enableKeyRotation: true,
removalPolicy: RemovalPolicy.RETAIN,
});
@@ -56,7 +56,11 @@ export class ImagingStack extends Stack {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
- objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
+ // CloudFront standard logging (legacy) requires ACLs and CDK
+ // explicitly requires OBJECT_WRITER for a custom log bucket.
+ // This bucket contains access logs only; the PHI-bearing imaging
+ // bucket below remains BUCKET_OWNER_ENFORCED with ACLs disabled.
+ objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
lifecycleRules: [{ expiration: Duration.days(365) }],
removalPolicy: RemovalPolicy.RETAIN,
});
@@ -86,7 +90,7 @@ export class ImagingStack extends Stack {
});
const distribution = new cloudfront.Distribution(this, "ImagingDistribution", {
- comment: "ModelForge imaging (DICOM) content delivery — see docs/IMAGING.md",
+ comment: "ModelForge imaging (DICOM) content delivery - see docs/IMAGING.md",
defaultBehavior: {
// withOriginAccessControl(): OAC, not the legacy OAI this
// table explicitly rules out — CDK wires the bucket policy
@@ -99,20 +103,14 @@ export class ImagingStack extends Stack {
},
logBucket: accessLogBucket,
logFilePrefix: "cloudfront/",
- minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
});
- // KMS grants: exactly the two principals the table names, nothing
- // broader. The CloudFront OAC service principal is scoped to this
- // one distribution's ARN — not "any CloudFront distribution in this
- // account" — via aws:SourceArn, the standard pattern for an
- // OAC-fronted, KMS-encrypted S3 origin.
- cmk.grant(
- new iam.ServicePrincipal("cloudfront.amazonaws.com", {
- conditions: { StringEquals: { "aws:SourceArn": `arn:${this.partition}:cloudfront::${this.account}:distribution/${distribution.distributionId}` } },
- }),
- "kms:Decrypt"
- );
+ // S3BucketOrigin.withOriginAccessControl() adds the CloudFront KMS
+ // decrypt statement itself. Its source ARN is limited to this AWS
+ // account's distributions but intentionally uses a distribution-ID
+ // wildcard: making the key policy depend on this distribution while
+ // the distribution also depends on the KMS-backed bucket creates a
+ // CloudFormation cycle and makes a first deployment impossible.
// Server task role: s3:PutObject/GetObject/DeleteObject on the
// bucket prefix, plus the KMS grants above. Explicitly NOT
@@ -121,7 +119,7 @@ export class ImagingStack extends Stack {
// identity actually runs server/ (see README.md).
const serverTaskRole = new iam.Role(this, "ImagingServerTaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
- description: "Least-privilege role for the server/ process's S3ImagingObjectStore — attach to whatever compute actually runs it (see infra/imaging-cdk/README.md).",
+ description: "Least-privilege role for server S3 imaging access; attach to the compute running server (see infra/imaging-cdk/README.md).",
});
bucket.grantPut(serverTaskRole);
bucket.grantRead(serverTaskRole);
diff --git a/infra/imaging-cdk/package.json b/infra/imaging-cdk/package.json
index f158ce2..1bfaa69 100644
--- a/infra/imaging-cdk/package.json
+++ b/infra/imaging-cdk/package.json
@@ -7,6 +7,7 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist cdk.out",
+ "test": "tsx --test test/*.test.ts",
"typecheck": "tsc --noEmit",
"synth": "cdk synth",
"diff": "cdk diff",
diff --git a/infra/imaging-cdk/test/imaging-stack.test.ts b/infra/imaging-cdk/test/imaging-stack.test.ts
new file mode 100644
index 0000000..2512b7c
--- /dev/null
+++ b/infra/imaging-cdk/test/imaging-stack.test.ts
@@ -0,0 +1,81 @@
+import assert from "node:assert/strict";
+import { generateKeyPairSync } from "node:crypto";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { App } from "aws-cdk-lib";
+import { Template } from "aws-cdk-lib/assertions";
+import { ImagingStack } from "../lib/imaging-stack.js";
+
+function synthesize(): Record {
+ const directory = mkdtempSync(join(tmpdir(), "modelforge-imaging-cdk-"));
+ try {
+ const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
+ const publicKeyPath = join(directory, "signing-public-key.pem");
+ writeFileSync(publicKeyPath, publicKey.export({ type: "spki", format: "pem" }));
+ const app = new App();
+ const stack = new ImagingStack(app, "TestImagingStack", {
+ env: { account: "111111111111", region: "eu-central-1" },
+ signingPublicKeyPath: publicKeyPath,
+ bucketNamePrefix: "modelforge-imaging-test",
+ });
+ return Template.fromStack(stack).toJSON();
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+}
+
+test("keeps ACLs enabled only on the dedicated CloudFront log bucket", () => {
+ const template = synthesize();
+ const resources = template.Resources as Record;
+ const imagingBucket = Object.values(resources).find((resource: any) =>
+ resource.Type === "AWS::S3::Bucket" && resource.Properties?.LoggingConfiguration
+ ) as any;
+ assert.ok(imagingBucket, "expected the imaging bucket with server-access logging");
+
+ const logBucketId = imagingBucket.Properties.LoggingConfiguration.DestinationBucketName.Ref;
+ const logBucket = resources[logBucketId];
+ assert.deepEqual(logBucket.Properties.OwnershipControls.Rules, [{ ObjectOwnership: "ObjectWriter" }]);
+ assert.deepEqual(imagingBucket.Properties.OwnershipControls.Rules, [{ ObjectOwnership: "BucketOwnerEnforced" }]);
+ assert.deepEqual(logBucket.Properties.PublicAccessBlockConfiguration, {
+ BlockPublicAcls: true,
+ BlockPublicPolicy: true,
+ IgnorePublicAcls: true,
+ RestrictPublicBuckets: true,
+ });
+});
+
+test("uses the OAC-generated account-scoped KMS grant without a distribution dependency cycle", () => {
+ const template = synthesize();
+ const key = Object.values(template.Resources as Record).find((resource: any) => resource.Type === "AWS::KMS::Key") as any;
+ assert.ok(key, "expected a customer-managed KMS key");
+ const cloudFrontStatements = key.Properties.KeyPolicy.Statement.filter(
+ (statement: any) => statement.Principal?.Service === "cloudfront.amazonaws.com"
+ );
+ assert.equal(cloudFrontStatements.length, 1);
+ assert.deepEqual(
+ cloudFrontStatements[0].Condition.ArnLike["AWS:SourceArn"],
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ { Ref: "AWS::Partition" },
+ ":cloudfront::",
+ { Ref: "AWS::AccountId" },
+ ":distribution/*",
+ ],
+ ],
+ }
+ );
+});
+
+test("keeps CloudFormation descriptions within the supported character set", () => {
+ const template = synthesize();
+ for (const resource of Object.values(template.Resources as Record) as any[]) {
+ const description = resource.Properties?.Description;
+ if (description === undefined) continue;
+ assert.match(description, /^[\u0009\u000A\u000D\u0020-\u007E\u00A1-\u00FF]*$/);
+ }
+});
diff --git a/server/Dockerfile b/server/Dockerfile
index 6b5386b..5bdeb38 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -5,8 +5,13 @@ COPY packages/contracts/package.json packages/contracts/tsconfig.json ./packages
COPY packages/contracts/src ./packages/contracts/src
COPY server/package.json server/package-lock.json ./server/
COPY server/vendor ./server/vendor
+# npm copies file: dependencies inside container builds. Point the server at
+# the workspace package so the development-stage contract build below is the
+# exact code imported by tsx, not a stale copy captured before that build ran.
RUN --mount=type=cache,target=/root/.npm \
npm --prefix server ci \
+ && rm -rf server/node_modules/@modelforge/contracts \
+ && ln -s ../../../packages/contracts server/node_modules/@modelforge/contracts \
&& ln -s ../../server/node_modules packages/contracts/node_modules
FROM dependencies AS development
diff --git a/server/migrations/022_case_migration_fingerprint_scope.sql b/server/migrations/022_case_migration_fingerprint_scope.sql
new file mode 100644
index 0000000..4306a65
--- /dev/null
+++ b/server/migrations/022_case_migration_fingerprint_scope.sql
@@ -0,0 +1,171 @@
+-- Fixes a real cross-user bug found via live multi-user testing of the
+-- local-to-shared case migration flow: case_migrations.source_fingerprint
+-- (sha256 of the caller's local case list) was UNIQUE on its own, with no
+-- per-user scoping. Two different members of the same organization who
+-- both stage with identical local case content — most commonly, both
+-- having zero local cases, the ordinary state for anyone new to a shared
+-- org — collide onto the exact same migration session via the store's
+-- `ON CONFLICT (source_fingerprint) DO UPDATE`. The second caller silently
+-- inherits the first caller's session (possibly already validated or even
+-- activated), and their own client never recognizes it as a session they
+-- can activate — case-migration.ts's stageLocalCases() only proceeds past
+-- "resume" for a session with a preview, and the "Activate shared dataset"
+-- button (AuditPrivacy.tsx) only renders for status "validated" — so the
+-- second user's own activation silently never becomes available even
+-- though the request nominally "succeeded."
+--
+-- Fix: scope the same idempotency-on-retry behavior (a caller repeating
+-- their own start() call with the same content reuses their own session,
+-- unchanged) to (source_fingerprint, created_by) instead of
+-- source_fingerprint alone. See postgres-case-migration-store.ts's start()
+-- and in-memory-case-migration-store.ts's start() for the matching
+-- application-side fix.
+--
+-- Same two-part structure as every migration since 015: Part 1 replaces
+-- provision_tenant_clinical_schema so every *future* organization's
+-- case_migrations table is created with the corrected constraint; Part 2
+-- backfills every already-provisioned tenant schema in place. Every block
+-- in Part 1 below is copied verbatim from migration 018's version of this
+-- function except the one UNIQUE clause called out inline.
+
+-- --- Part 1: extend provision_tenant_clinical_schema -----------------------
+
+CREATE OR REPLACE FUNCTION provision_tenant_clinical_schema(target_org UUID)
+RETURNS TEXT
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = pg_catalog, public
+AS $provision$
+DECLARE
+ schema_name TEXT := 'tenant_' || replace(target_org::text, '-', '');
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM public.organizations WHERE id = target_org) THEN
+ RAISE EXCEPTION 'Unknown organization';
+ END IF;
+ UPDATE public.organizations SET tenant_schema = schema_name WHERE id = target_org;
+ EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name);
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'modelforge_runtime') THEN
+ EXECUTE format('GRANT USAGE ON SCHEMA %I TO modelforge_runtime', schema_name);
+ EXECUTE format(
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO modelforge_runtime',
+ CURRENT_USER, schema_name
+ );
+ END IF;
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.patient_cases (
+ case_id TEXT PRIMARY KEY,
+ version BIGINT NOT NULL,
+ data JSONB NOT NULL,
+ patient_id TEXT NOT NULL,
+ owner_user_id UUID NOT NULL,
+ workspace_id TEXT,
+ department_id TEXT,
+ assigned_user_ids UUID[] NOT NULL DEFAULT ''{}'',
+ active_consent_scopes TEXT[] NOT NULL DEFAULT ''{}'',
+ staged_migration_id UUID,
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ updated_at TIMESTAMPTZ NOT NULL
+ )', schema_name
+ );
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.case_change_counter (
+ singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
+ next_sequence BIGINT NOT NULL DEFAULT 1
+ )', schema_name
+ );
+ EXECUTE format('INSERT INTO %I.case_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name);
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.case_changes (
+ sequence BIGINT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')),
+ case_id TEXT NOT NULL,
+ version BIGINT NOT NULL,
+ patient_case JSONB,
+ resource JSONB NOT NULL,
+ changed_at TIMESTAMPTZ NOT NULL
+ )', schema_name
+ );
+ EXECUTE format('ALTER TABLE %I.case_changes ADD COLUMN IF NOT EXISTS resource JSONB', schema_name);
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.case_changes (case_id, sequence DESC)', 'idx_' || schema_name || '_case_changes_case', schema_name);
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.case_migrations (
+ id UUID PRIMARY KEY,
+ status TEXT NOT NULL CHECK (status IN (''staging'', ''validated'', ''active'', ''rolled-back'')),
+ source_fingerprint TEXT NOT NULL,
+ total_items INTEGER NOT NULL,
+ accepted_items INTEGER NOT NULL DEFAULT 0,
+ preview JSONB,
+ created_by UUID NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL,
+ UNIQUE (source_fingerprint, created_by)
+ )', schema_name
+ );
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.case_migration_items (
+ migration_id UUID NOT NULL REFERENCES %I.case_migrations(id) ON DELETE CASCADE,
+ item_key TEXT NOT NULL,
+ case_id TEXT NOT NULL,
+ data JSONB NOT NULL,
+ data_hash TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN (''pending'', ''accepted'', ''invalid'', ''collision'')),
+ errors JSONB NOT NULL DEFAULT ''[]'',
+ PRIMARY KEY (migration_id, item_key)
+ )', schema_name, schema_name
+ );
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.chat_sessions (
+ id TEXT PRIMARY KEY,
+ version BIGINT NOT NULL,
+ data JSONB NOT NULL,
+ owner_user_id UUID NOT NULL,
+ assigned_user_ids UUID[] NOT NULL DEFAULT ''{}'',
+ updated_at TIMESTAMPTZ NOT NULL
+ )', schema_name
+ );
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.chat_session_change_counter (
+ singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
+ next_sequence BIGINT NOT NULL DEFAULT 1
+ )', schema_name
+ );
+ EXECUTE format('INSERT INTO %I.chat_session_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name);
+ EXECUTE format(
+ 'CREATE TABLE IF NOT EXISTS %I.chat_session_changes (
+ sequence BIGINT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')),
+ session_id TEXT NOT NULL,
+ version BIGINT NOT NULL,
+ session_data JSONB,
+ resource JSONB NOT NULL,
+ changed_at TIMESTAMPTZ NOT NULL
+ )', schema_name
+ );
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.chat_session_changes (session_id, sequence DESC)', 'idx_' || schema_name || '_chat_session_changes_session', schema_name);
+
+ PERFORM provision_tenant_imaging_tables(schema_name);
+ PERFORM provision_tenant_ai_gateway_tables(schema_name);
+
+ RETURN schema_name;
+END
+$provision$;
+
+REVOKE ALL ON FUNCTION provision_tenant_clinical_schema(UUID) FROM PUBLIC;
+
+-- --- Part 2: backfill every already-provisioned tenant schema --------------
+DO $backfill$
+DECLARE
+ org RECORD;
+BEGIN
+ FOR org IN SELECT tenant_schema FROM organizations WHERE tenant_schema IS NOT NULL LOOP
+ -- "case_migrations_source_fingerprint_key" is Postgres's default
+ -- autogenerated name for the original inline UNIQUE (source_fingerprint)
+ -- column constraint declared in migrations 009 through 018.
+ EXECUTE format('ALTER TABLE %I.case_migrations DROP CONSTRAINT IF EXISTS case_migrations_source_fingerprint_key', org.tenant_schema);
+ EXECUTE format(
+ 'ALTER TABLE %I.case_migrations ADD CONSTRAINT case_migrations_source_fingerprint_created_by_key UNIQUE (source_fingerprint, created_by)',
+ org.tenant_schema
+ );
+ END LOOP;
+END
+$backfill$;
diff --git a/server/src/store/in-memory-case-migration-store.ts b/server/src/store/in-memory-case-migration-store.ts
index 5c3844b..2797097 100644
--- a/server/src/store/in-memory-case-migration-store.ts
+++ b/server/src/store/in-memory-case-migration-store.ts
@@ -7,7 +7,7 @@ import { MigrationStateError, type CaseMigrationStore, type TenantCaseMigrationR
import type { TenantCaseRepository } from "./case-store.js";
interface StagedItem { itemKey: string; patientCase: unknown; hash: string; status: "pending" | "accepted" | "invalid" | "collision"; errors: string[] }
-interface State { session: MigrationSession; items: Map; activated: Map }
+interface State { session: MigrationSession; items: Map; activated: Map; createdBy: string | undefined }
const digest = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("hex");
export class InMemoryCaseMigrationStore implements CaseMigrationStore {
@@ -24,11 +24,17 @@ export class InMemoryCaseMigrationStore implements CaseMigrationStore {
const requireState = (id: string): State => { const state = sessions.get(id); if (!state) throw Object.assign(new Error("Migration not found."), { statusCode: 404 }); return state; };
return {
start: async (input, actor) => {
- const existing = [...sessions.values()].find((state) => state.session.sourceFingerprint === input.sourceFingerprint && state.session.status !== "rolled-back");
+ // Scoped by creator as well as content — see the Postgres
+ // store's identical fix and migrations/022_case_migration_fingerprint_scope.sql
+ // for why sourceFingerprint alone is not a safe dedup key
+ // across different users.
+ const existing = [...sessions.values()].find(
+ (state) => state.session.sourceFingerprint === input.sourceFingerprint && state.createdBy === actor.userId && state.session.status !== "rolled-back"
+ );
if (existing) return existing.session;
const now = new Date().toISOString();
const session: MigrationSession = { id: randomUUID(), organizationId: context.organizationId, status: "staging", sourceFingerprint: input.sourceFingerprint, totalItems: input.totalItems, acceptedItems: 0, createdAt: now, updatedAt: now };
- sessions.set(session.id, { session, items: new Map(), activated: new Map() });
+ sessions.set(session.id, { session, items: new Map(), activated: new Map(), createdBy: actor.userId });
await this.audit.record({ organizationId: context.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "caseMigration.start", targetType: "caseMigration", targetId: session.id });
return session;
},
diff --git a/server/src/store/postgres-case-migration-store.test.ts b/server/src/store/postgres-case-migration-store.test.ts
index 678c0eb..4465381 100644
--- a/server/src/store/postgres-case-migration-store.test.ts
+++ b/server/src/store/postgres-case-migration-store.test.ts
@@ -78,6 +78,24 @@ describe.skipIf(!DATABASE_URL)("PostgresCaseMigrationStore (integration — requ
expect(rows.rows[0].n).toBe(1);
});
+ it("start() scopes the fingerprint dedup key per user — two org members with identical local case content (e.g. both empty) get distinct sessions, not the same one", async () => {
+ const otherActor: AuditActor = { externalSubject: "idp|test-actor-2", userId: randomUUID() };
+ const mine = await repo.start({ sourceFingerprint: "identical-content", totalItems: 0 }, ACTOR);
+ const theirs = await repo.start({ sourceFingerprint: "identical-content", totalItems: 0 }, otherActor);
+ expect(theirs.id).not.toBe(mine.id);
+
+ // Regression coverage for the real bug: activating MY session must
+ // never affect the OTHER actor's still-independent session — before
+ // this fix, they were the exact same row, so this validate() would
+ // have failed with "cannot be validated in its current state" once
+ // the shared session moved to "active".
+ await repo.validate(mine.id, ACTOR);
+ const activated = await repo.activate(mine.id, ACTOR);
+ expect(activated.status).toBe("active");
+ const theirsStillStaging = await repo.get(theirs.id);
+ expect(theirsStillStaging?.status).toBe("staging");
+ });
+
it("upload() is idempotent per item key — replaying the same batch does not duplicate items, but reusing a key with different data is rejected", async () => {
const session = await repo.start({ sourceFingerprint: "fp-upload", totalItems: 1 }, ACTOR);
const original = patientCaseFixture("case-1");
@@ -138,7 +156,7 @@ describe.skipIf(!DATABASE_URL)("PostgresCaseMigrationStore (integration — requ
await repo.rollback(session.id, ACTOR);
const rows = await pool.query(
- "SELECT action FROM audit_log WHERE organization_id = $1 AND target_id = $2 ORDER BY created_at",
+ "SELECT action FROM audit_log WHERE organization_id = $1 AND target_id = $2 ORDER BY sequence::bigint",
[orgId, session.id]
);
expect(rows.rows.map((r: { action: string }) => r.action)).toEqual([
diff --git a/server/src/store/postgres-case-migration-store.ts b/server/src/store/postgres-case-migration-store.ts
index 028c756..21ba6cc 100644
--- a/server/src/store/postgres-case-migration-store.ts
+++ b/server/src/store/postgres-case-migration-store.ts
@@ -29,7 +29,16 @@ export class PostgresCaseMigrationStore implements CaseMigrationStore {
private async begin(client: PoolClient, context: TenantContext): Promise { await client.query("BEGIN"); await client.query("SELECT set_config('app.tenant_id',$1,true)",[context.organizationId]); }
private async get(context: TenantContext, schema: string, id: string): Promise { const result = await this.pool.query(`SELECT * FROM ${schema}.case_migrations WHERE id=$1`,[id]); return result.rows[0] ? mapSession(result.rows[0],context.organizationId) : null; }
private async start(context: TenantContext, schema: string, input: { sourceFingerprint: string; totalItems: number }, actor: AuditActor): Promise {
- const client=await this.pool.connect(); try { await this.begin(client,context); const now=new Date(); const result=await client.query(`INSERT INTO ${schema}.case_migrations (id,status,source_fingerprint,total_items,accepted_items,created_by,created_at,updated_at) VALUES ($1,'staging',$2,$3,0,$4,$5,$5) ON CONFLICT (source_fingerprint) DO UPDATE SET source_fingerprint=EXCLUDED.source_fingerprint RETURNING *`,[randomUUID(),input.sourceFingerprint,input.totalItems,actor.userId,now]); await insertAuditEntry(client,{organizationId:context.organizationId,actorUserId:actor.userId,actorExternalSubject:actor.externalSubject,action:"caseMigration.start",targetType:"caseMigration",targetId:result.rows[0].id}); await client.query("COMMIT"); return mapSession(result.rows[0],context.organizationId); } catch(e){await client.query("ROLLBACK");throw e;} finally{client.release();}
+ // The conflict target is (source_fingerprint, created_by), not
+ // source_fingerprint alone — see migrations/022_case_migration_fingerprint_scope.sql.
+ // A bare source_fingerprint key means any two org members (or the
+ // same user across two separate attempts, since sha256("[]") is
+ // identical every time for someone with zero local cases) who stage
+ // with identical local case content collide onto the exact same
+ // session, silently handing the second caller a session — possibly
+ // already validated or activated — that they never started and that
+ // their own client's Activate button won't recognize as theirs.
+ const client=await this.pool.connect(); try { await this.begin(client,context); const now=new Date(); const result=await client.query(`INSERT INTO ${schema}.case_migrations (id,status,source_fingerprint,total_items,accepted_items,created_by,created_at,updated_at) VALUES ($1,'staging',$2,$3,0,$4,$5,$5) ON CONFLICT (source_fingerprint, created_by) DO UPDATE SET source_fingerprint=EXCLUDED.source_fingerprint RETURNING *`,[randomUUID(),input.sourceFingerprint,input.totalItems,actor.userId,now]); await insertAuditEntry(client,{organizationId:context.organizationId,actorUserId:actor.userId,actorExternalSubject:actor.externalSubject,action:"caseMigration.start",targetType:"caseMigration",targetId:result.rows[0].id}); await client.query("COMMIT"); return mapSession(result.rows[0],context.organizationId); } catch(e){await client.query("ROLLBACK");throw e;} finally{client.release();}
}
private async upload(context: TenantContext, schema: string, id: string, items: { itemKey:string;patientCase:unknown }[], actor: AuditActor): Promise {
const client=await this.pool.connect(); try { await this.begin(client,context); const session=await client.query(`SELECT * FROM ${schema}.case_migrations WHERE id=$1 FOR UPDATE`,[id]); if(!session.rows[0]) throw Object.assign(new Error("Migration not found."),{statusCode:404}); if(session.rows[0].status!=="staging") throw new MigrationStateError("Migration is not accepting batches.");