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.");