Sources (correcting what I built earlier) The previous toggle branched the SYSTEM PROMPT, so the same question could get a different answer depending on a display setting — the bias this was meant to avoid. The prompt is now unconditional: buildSystemPrompt takes no display argument and is byte-identical either way. Hiding sources happens on the way out — the server omits them and strips the now-orphaned [n] markers from the copy it sends. The answer is generated, stored and exported with citations intact, so turning the setting back on restores them without re-asking anything. Renamed to clinical_assistant.show_sources; the old key is still honoured. Signed-out preview (admin opt-in, default off) A visitor may try the assistant; reaching for the workspace asks them to sign in. Deliberately narrow: - Reachable paths are an exact allow-list, not a pattern, so a new endpoint is private unless someone adds it on purpose. - A preview visitor gets no identity at all (id: null), so nothing can be owned, saved, billed or addressed to them. - The image tool is withheld rather than left to fail on a null owner, and no audit rows are written. - A caller presenting a token is authenticated normally, so preview can never downgrade a real session; if the setting cannot be read, authentication is required. - Actions needing an account are hidden rather than offered and refused. Composer The bar above the transcript is gone. Patient take home, Export PDF, Download transcript and Attach images moved into a + menu in the composer, and the model selector moved beside send — shown only when there is more than one model, as before. Both views now start at the same top edge, so switching modes cannot nudge the page up or down. On an empty transcript the tiled ground runs behind and below the composer, which floats on it above centre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
139 lines
9.1 KiB
JavaScript
139 lines
9.1 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { pathToFileURL } = require('node:url');
|
|
const { JSDOM } = require('jsdom');
|
|
const { normalizeMcpSearchResponse } = require('../src/utils/clinicalRetrieval');
|
|
const { savedChatPayload } = require('../src/utils/clinicalConversation');
|
|
const root = path.join(__dirname, '..');
|
|
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
|
const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
|
|
|
|
// Execute the actual static-route registration, without starting the application.
|
|
test('actual markup loads the existing local DOMPurify distribution through its vendor route', async t => {
|
|
const express = require('express');
|
|
const app = express();
|
|
const source = read('server.js');
|
|
const start = source.indexOf("app.use('/vendor/dompurify',");
|
|
const end = source.indexOf("app.use('/vendor/markdown-it',", start);
|
|
assert.ok(start >= 0 && end > start);
|
|
vm.runInNewContext(source.slice(start, end), { app, express, path, __dirname: root });
|
|
const server = app.listen(0, '127.0.0.1');
|
|
t.after(() => server.close());
|
|
await new Promise(resolve => server.on('listening', resolve));
|
|
const doc = new JSDOM(read('public/index.html')).window.document;
|
|
const scripts = [...doc.querySelectorAll('script[src]')].filter(script => /dompurify/i.test(script.src));
|
|
assert.equal(scripts.length, 1);
|
|
assert.equal(scripts[0].getAttribute('src'), '/vendor/dompurify/purify.min.js');
|
|
const response = await fetch('http://127.0.0.1:' + server.address().port + scripts[0].getAttribute('src'));
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers.get('cache-control'), /max-age=3600/);
|
|
assert.equal(await response.text(), read('node_modules/dompurify/dist/purify.min.js'));
|
|
doc.defaultView.close();
|
|
});
|
|
|
|
test('native admin and assistant modules retain budget, table/source identity and safe live/saved/export rendering with or without DOMPurify', async t => {
|
|
const dom = new JSDOM('<div id="admin-tab">' + read('public/components/admin.html') + '</div><div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://app.example', runScripts: 'outside-only' });
|
|
const { window } = dom;
|
|
window.eval(read('public/js/accountBoundary.js'));
|
|
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
|
const document = window.document;
|
|
const calls = [];
|
|
const limit = 2000;
|
|
let saved;
|
|
const rows = Array.from({ length: 65 }, (_, i) => '| Drug ' + i + ' | 2 mg/kg |');
|
|
const sources = normalizeMcpSearchResponse({ results: [{ id: 42, title: 'Synthetic', page_number: 7, excerpt: 'Table 1. Synthetic\n\n| Drug | Dose |\n|---|---|\n' + rows.join('\n') + '\n\nNote: Synthetic only.' }] });
|
|
assert.ok(sources[0].excerpt.length > 900);
|
|
const markdown = '| Drug | Dose | Sources |\n|---|---|---|\n| Synthetic | 2 mg/kg | [1] |\n\n<img src=x onerror="alert(1)"><svg onload="alert(2)"></svg><script>alert(3)</script>\n\n<a href="javascript:alert(4)">bad</a>';
|
|
const fetchMock = async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
if (url === '/api/clinical-assistant/chat/stream') return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: markdown, sources }) + '\n\n');
|
|
let data = { success: true, models: [] };
|
|
if (url === '/api/admin/config') {
|
|
data.config = [{ key: 'clinical_assistant.conversation_chars', value: '999999' }];
|
|
data.conversationBudget = { limit, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' };
|
|
}
|
|
else if (url === '/api/clinical-assistant/status') data.conversationChars = limit;
|
|
else if (url === '/api/clinical-assistant/chats' && options.method === 'POST') saved = savedChatPayload(JSON.parse(options.body));
|
|
else if (url === '/api/clinical-assistant/chats') data.chats = saved ? [{ id: 1, title: 'Synthetic saved chat' }] : [];
|
|
else if (url === '/api/clinical-assistant/chats/1') data.chat = { payload: saved };
|
|
else if (url.endsWith('/examples')) data.examples = [];
|
|
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
|
};
|
|
const values = { window, document, fetch: fetchMock, showToast() {}, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }) };
|
|
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
|
|
Object.assign(global, values);
|
|
window.getAuthHeaders = values.getAuthHeaders;
|
|
window.showToast = values.showToast;
|
|
window.confirm = () => true;
|
|
window.marked = require('marked').marked;
|
|
window.matchMedia = () => ({ matches: true }); // Exercise the real inline export, without printing/downloading.
|
|
t.after(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 2000)); // let autosave + late fetch chains settle against this mock
|
|
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
|
|
window.close();
|
|
});
|
|
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
|
|
await import(pathToFileURL(path.join(root, 'public/js/clinicalAssistant.js')).href);
|
|
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
|
await tick();
|
|
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
|
|
assert.equal(document.getElementById('assistant-conversation-budget').value, '999999');
|
|
assert.equal(document.getElementById('assistant-conversation-budget').readOnly, false, 'the budget is admin-editable');
|
|
document.getElementById('btn-save-assistant-config').click();
|
|
await tick();
|
|
assert.equal(limit, 2000);
|
|
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 9, 'one native admin initializer; prompts are not generic setting saves');
|
|
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is an admin-settable override');
|
|
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
|
await tick(); await tick(); await tick();
|
|
const input = document.getElementById('assistant-input');
|
|
input.value = 'x'.repeat(2001);
|
|
input.dispatchEvent(new window.Event('input'));
|
|
assert.match(document.getElementById('assistant-context-warning').textContent, /Sending is blocked/, 'the limit is known before the ask');
|
|
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
|
|
await tick();
|
|
assert.equal(input.value.length, 2001, 'over-budget draft retained');
|
|
assert.equal(calls.filter(call => call.url.endsWith('/chat/stream')).length, 0);
|
|
|
|
function assertSafe(element, purified) {
|
|
assert.ok(element);
|
|
assert.equal(element.querySelector('script, [onerror], [onload], a[href^="javascript:"]'), null);
|
|
if (purified) {
|
|
assert.equal(element.querySelectorAll('.assistant-table-scroll table tbody tr').length, 1);
|
|
const target = element.id === 'assistant-export-modal' ? '#ref-1-1' : '#assistant-source-1';
|
|
assert.equal(element.querySelector('.assistant-cite').getAttribute('href'), target);
|
|
assert.ok(document.querySelector(target), 'citation target exists in the displayed chat/export');
|
|
assert.match(element.querySelector('td').textContent, /Synthetic/);
|
|
} else {
|
|
assert.equal(element.querySelector('table, a, img, svg'), null, 'fallback is escaped text, not raw HTML');
|
|
assert.match(element.textContent, /onerror=/, 'unsafe markup remains inert text');
|
|
}
|
|
}
|
|
for (const purified of [false, true]) {
|
|
if (purified) window.eval(read('node_modules/dompurify/dist/purify.min.js'));
|
|
else delete window.DOMPurify;
|
|
document.getElementById('btn-assistant-clear').click();
|
|
input.value = 'Synthetic table question';
|
|
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
|
|
await tick();
|
|
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
|
|
await new Promise(resolve => setTimeout(resolve, 900)); // autosave debounce after the completed turn
|
|
assert.ok(saved, 'autosave persisted the completed turn');
|
|
assert.equal(saved.messages[1].content, markdown);
|
|
assert.equal(saved.sources[0].excerpt, sources[0].excerpt);
|
|
document.getElementById('btn-assistant-clear').click();
|
|
document.querySelector('[data-assistant-load-chat="1"]').click();
|
|
await tick();
|
|
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
|
|
const excerpt = document.querySelector('.assistant-source-excerpt p');
|
|
assert.equal(excerpt.textContent, sources[0].excerpt);
|
|
assert.equal(excerpt.style.whiteSpace, 'pre-wrap');
|
|
assert.match(excerpt.textContent, /Note: Synthetic only\.$/);
|
|
document.getElementById('btn-assistant-export-pdf').click();
|
|
assertSafe(document.querySelector('#assistant-export-modal'), purified);
|
|
document.querySelector('#assistant-export-modal #assistant-export-close').click();
|
|
}
|
|
});
|