From 40e7dd5206ebf67969b7cc2d6235b8ccfdb259a8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 07:45:48 +0200 Subject: [PATCH] fix: bind clinical image and export actions to their original account --- public/js/assistant/export.js | 113 +++++--- public/js/assistant/images.js | 190 ++++++++++---- public/js/assistant/sharing.js | 24 ++ test/assistant-saved-tables.test.js | 6 +- test/assistant-sharing-boundary.test.js | 297 ++++++++++++++++++++++ test/clinical-release-integration.test.js | 2 + test/frontend-prompt-env.test.js | 6 +- 7 files changed, 556 insertions(+), 82 deletions(-) create mode 100644 public/js/assistant/sharing.js create mode 100644 test/assistant-sharing-boundary.test.js diff --git a/public/js/assistant/export.js b/public/js/assistant/export.js index 83ec24b..9467841 100644 --- a/public/js/assistant/export.js +++ b/public/js/assistant/export.js @@ -1,9 +1,11 @@ import { escapeAttr, escapeHtml } from './citations.js'; +import { captureSharingOwner, assertSharingOwner, validSharingOwner, sharingFilename } from './sharing.js'; export function createAssistantExporter(options) { options = options || {}; var exportCacheKey = ''; var exportCacheItems = null; + var inlineExportClose = null; function invalidate() { exportCacheKey = ''; @@ -11,6 +13,8 @@ export function createAssistantExporter(options) { } function exportAnswerPdf(state) { + var owner; + try { owner = captureSharingOwner(); } catch (e) { return; } state = state || {}; if (!state.lastAnswer) { if (typeof options.showToast === 'function') options.showToast('No answer to export', 'error'); @@ -19,74 +23,111 @@ export function createAssistantExporter(options) { var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || [], options.presentMessage); var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || ''); if (exportCacheKey === cacheKey && exportCacheItems) { - showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || ''); + showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || '', owner); return; } exportCacheKey = cacheKey; exportCacheItems = exportItems; - showPrintableExport(exportItems, state.lastGeneratedImageSrc || ''); + showPrintableExport(exportItems, state.lastGeneratedImageSrc || '', owner); } - function showPrintableExport(items, imageSrc) { + function showPrintableExport(items, imageSrc, owner) { + if (!validSharingOwner(owner)) return; if (shouldUseInlineExport()) { - writeInlineChatExport(items, imageSrc); + writeInlineChatExport(items, imageSrc, owner); return; } - var doc = openExportWindow(); + var doc = openExportWindow(owner); if (!doc) { - if (typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error'); + if (validSharingOwner(owner) && typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error'); return; } - writePrintableChatExport(doc, items, imageSrc); + writePrintableChatExport(doc, items, imageSrc, owner); } - function openExportWindow() { + function openExportWindow(owner) { + assertSharingOwner(owner); var doc = window.open('', '_blank', 'width=900,height=1100'); if (!doc) return null; + if (!validSharingOwner(owner)) { doc.close(); return null; } doc.document.open(); doc.document.write('Preparing Clinical Assistant Export

Preparing PDF export...

'); doc.document.close(); return doc; } - function writePrintableChatExport(doc, items, imageSrc) { + function writePrintableChatExport(doc, items, imageSrc, owner) { if (!doc || doc.closed) return; + var timer; + function close() { + clearTimeout(timer); + owner.signal.removeEventListener('abort', close); + try { doc.close(); } catch (e) { /* Only this export window is owned here. */ } + } + function print() { + if (!validSharingOwner(owner)) { close(); return; } + if (doc.closed) return; + try { doc.focus(); assertSharingOwner(owner); doc.print(); } catch (e) {} + } + owner.signal.addEventListener('abort', close, { once: true }); var html = buildPrintableChatHtml(items, imageSrc, false); + if (!validSharingOwner(owner)) { close(); return; } doc.document.open(); doc.document.write(html); doc.document.close(); - try { - var printBtn = doc.document.getElementById('assistant-export-print'); - if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); }); - var closeBtn = doc.document.getElementById('assistant-export-close'); - if (closeBtn) closeBtn.addEventListener('click', function () { try { doc.close(); } catch (e) { doc.location.href = '/'; } }); - } catch (e) {} - setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500); + var printBtn = doc.document.getElementById('assistant-export-print'); + if (printBtn) printBtn.addEventListener('click', print); + var closeBtn = doc.document.getElementById('assistant-export-close'); + if (closeBtn) closeBtn.addEventListener('click', close); + timer = setTimeout(print, 500); } - function writeInlineChatExport(items, imageSrc) { + function writeInlineChatExport(items, imageSrc, owner) { + assertSharingOwner(owner); closeInlineExport(); var modal = document.createElement('div'); modal.id = 'assistant-export-modal'; modal.innerHTML = '
' + buildPrintableChatBody(items, imageSrc, true) + '
'; + if (!validSharingOwner(owner)) return; + function close() { + modal.remove(); + owner.signal.removeEventListener('abort', close); + window.removeEventListener('popstate', close); + if (inlineExportClose === close) { + inlineExportClose = null; + document.body.classList.remove('assistant-export-open'); + } + } + inlineExportClose = close; + owner.signal.addEventListener('abort', close, { once: true }); document.body.appendChild(modal); document.body.classList.add('assistant-export-open'); var printBtn = modal.querySelector('#assistant-export-print'); var closeBtn = modal.querySelector('#assistant-export-close'); if (printBtn) printBtn.addEventListener('click', async function () { + if (!validSharingOwner(owner)) { close(); return; } printBtn.disabled = true; var originalText = printBtn.textContent; printBtn.textContent = 'Preparing export...'; try { var html = buildPrintableChatHtml(items, imageSrc, false); - if (await printWithNativeBridge(html)) return; - if (!(await saveInlineExport(html))) window.print(); + var printed = await printWithNativeBridge(html, owner); + assertSharingOwner(owner); + if (printed) return; + var saved = await saveInlineExport(html, owner); + assertSharingOwner(owner); + if (!saved) window.print(); + } catch (e) { + if (!validSharingOwner(owner) || e.name === 'AbortError') return; + if (typeof options.showToast === 'function') options.showToast(e.message || 'Export failed', 'error'); } finally { - printBtn.disabled = false; - printBtn.textContent = originalText; + if (validSharingOwner(owner)) { + printBtn.disabled = false; + printBtn.textContent = originalText; + } else close(); } }); - if (closeBtn) closeBtn.addEventListener('click', closeInlineExport); + if (closeBtn) closeBtn.addEventListener('click', close); modal.addEventListener('click', function (event) { if (!event.defaultPrevented && !event.button && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) { var citation = event.target.closest('.assistant-cite'); @@ -100,16 +141,14 @@ export function createAssistantExporter(options) { reference.scrollIntoView({ block: 'nearest' }); } } - if (event.target === modal) closeInlineExport(); + if (event.target === modal) close(); }); try { window.history.pushState({ assistantExport: true }, '', window.location.href); } catch (e) {} - window.addEventListener('popstate', closeInlineExport, { once: true }); + window.addEventListener('popstate', close, { once: true }); } function closeInlineExport() { - var existing = document.getElementById('assistant-export-modal'); - if (existing) existing.remove(); - document.body.classList.remove('assistant-export-open'); + if (inlineExportClose) inlineExportClose(); } function buildPrintableChatHtml(items, imageSrc, inline) { @@ -162,33 +201,43 @@ function shouldUseInlineExport() { return isCapacitor || isSmallTouch; } -async function saveInlineExport(html) { +async function saveInlineExport(html, owner) { + assertSharingOwner(owner); var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null; if (!plugins || !plugins.Filesystem) return false; try { - var name = 'clinical-assistant-export-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.html'; + var name = sharingFilename('clinical-assistant-export', 'html'); var saved = await plugins.Filesystem.writeFile({ path: name, data: utf8ToBase64(html), directory: 'CACHE' }); + assertSharingOwner(owner); if (plugins.Share && saved && saved.uri) { try { await plugins.Share.share({ title: 'Clinical Assistant Export', text: 'Open this export and use Print to save as PDF.', url: saved.uri, dialogTitle: 'Save or share export' }); + assertSharingOwner(owner); } catch (e) { - if (!isShareCancel(e)) throw e; + assertSharingOwner(owner); + if (e.name === 'AbortError' || !isShareCancel(e)) throw e; } } if (typeof window.showToast === 'function') window.showToast('Export prepared', 'success'); return true; } catch (e) { + assertSharingOwner(owner); + if (e.name === 'AbortError') throw e; if (typeof window.showToast === 'function') window.showToast(e.message || 'Export failed', 'error'); return false; } } -async function printWithNativeBridge(html) { +async function printWithNativeBridge(html, owner) { + assertSharingOwner(owner); if (!window.NativePrint || typeof window.NativePrint.printHtml !== 'function') return false; try { - window.NativePrint.printHtml('Clinical Assistant Export', utf8ToBase64(html)); + await window.NativePrint.printHtml('Clinical Assistant Export', utf8ToBase64(html)); + assertSharingOwner(owner); return true; } catch (e) { + assertSharingOwner(owner); + if (e.name === 'AbortError') throw e; if (typeof window.showToast === 'function') window.showToast(e.message || 'Native print failed', 'error'); return false; } diff --git a/public/js/assistant/images.js b/public/js/assistant/images.js index b511012..6807846 100644 --- a/public/js/assistant/images.js +++ b/public/js/assistant/images.js @@ -1,9 +1,12 @@ +import { captureSharingOwner, assertSharingOwner, validSharingOwner, sharingFilename } from './sharing.js'; import { escapeAttr } from './citations.js'; export function createAssistantImageStore() { var generatedImages = {}; var generatedImageSeq = 0; var previewKeyHandler = null; + var previewModal = null; + var previewOwner = null; function renderGeneratedImage(src, alt, downloadUrl) { var id = 'img-' + (++generatedImageSeq); @@ -19,8 +22,13 @@ export function createAssistantImageStore() { var item = generatedImages[id]; var src = item && (item.src || item); if (!src) return; + var owner; + try { owner = captureSharingOwner(); } catch (e) { return; } closeImagePreview(); + previewOwner = owner; + owner.signal.addEventListener('abort', closeImagePreview, { once: true }); var modal = document.createElement('div'); + previewModal = modal; modal.className = 'assistant-image-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); @@ -35,7 +43,10 @@ export function createAssistantImageStore() { } function closeImagePreview() { - document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); }); + if (previewModal) previewModal.remove(); + previewModal = null; + if (previewOwner) previewOwner.signal.removeEventListener('abort', closeImagePreview); + previewOwner = null; document.body.classList.remove('assistant-image-preview-open'); if (previewKeyHandler) document.removeEventListener('keydown', previewKeyHandler); previewKeyHandler = null; @@ -47,11 +58,15 @@ export function createAssistantImageStore() { var downloadUrl = item && item.downloadUrl; if (!src) return; try { + var ticket = captureSharingOwner(); if (downloadUrl) { - await downloadFromServer(downloadUrl); + await downloadFromServer(downloadUrl, ticket); + assertSharingOwner(ticket); return; } - if (await saveWithNativeShare(src)) return; + var saved = await saveWithNativeShare(src, ticket); + assertSharingOwner(ticket); + if (saved) return; if (isNativeApp()) { if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error'); return; @@ -60,8 +75,10 @@ export function createAssistantImageStore() { if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info'); return; } - await downloadWithBrowser(src); + await downloadWithBrowser(src, ticket); + assertSharingOwner(ticket); } catch (e) { + if (!validSharingOwner(ticket) || e.name === 'AbortError') return; if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error'); } } @@ -80,23 +97,31 @@ export function createAssistantImageStore() { }; } -async function saveWithNativeShare(src) { +async function saveWithNativeShare(src, ticket) { + assertSharingOwner(ticket); if (isNativeApp()) { - if (await saveWithNativeImageBridge(src)) return true; - return await saveWithCapacitorShare(src); + var nativeSaved = await saveWithNativeImageBridge(src, ticket); + assertSharingOwner(ticket); + if (nativeSaved) return true; + } else { + var webShare = await shareWithWebFile(src, ticket); + assertSharingOwner(ticket); + if (webShare !== 'unavailable') return true; } - var webShare = await shareWithWebFile(src); - if (webShare !== 'unavailable') return true; - - return await saveWithCapacitorShare(src); + var saved = await saveWithCapacitorShare(src, ticket); + assertSharingOwner(ticket); + return saved; } -async function saveWithNativeImageBridge(src) { +async function saveWithNativeImageBridge(src, ticket) { + assertSharingOwner(ticket); if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false; try { - var base64 = await imageSourceToBase64(src); - var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png'; + var base64 = await imageSourceToBase64(src, ticket); + assertSharingOwner(ticket); + var name = sharingFilename('clinical-visual', 'png'); var result = String(window.NativeFiles.saveImage(name, base64) || ''); + assertSharingOwner(ticket); if (result.indexOf('saved:') === 0) { if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success'); return true; @@ -105,57 +130,84 @@ async function saveWithNativeImageBridge(src) { window.showToast(result.slice(6) || 'Native image save failed', 'error'); } } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; if (typeof window.showToast === 'function') window.showToast(e.message || 'Native image save failed', 'error'); } return false; } -async function saveWithCapacitorShare(src) { +async function saveWithCapacitorShare(src, ticket) { + assertSharingOwner(ticket); var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null; if (!plugins) return false; if (!plugins.Filesystem && plugins.Share && !/^data:image\//i.test(src)) { try { + assertSharingOwner(ticket); await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: src, dialogTitle: 'Save or share clinical visual' }); + assertSharingOwner(ticket); return true; - } catch (e) { return isShareCancel(e); } + } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; + return isShareCancel(e); + } } if (!plugins.Filesystem) return false; try { - var base64 = await imageSourceToBase64(src); - var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png'; + var base64 = await imageSourceToBase64(src, ticket); + assertSharingOwner(ticket); + var name = sharingFilename('clinical-visual', 'png'); var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' }); + assertSharingOwner(ticket); if (plugins.Share && saved && saved.uri) { try { + assertSharingOwner(ticket); await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' }); + assertSharingOwner(ticket); } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; if (!isShareCancel(e)) throw e; } } if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success'); return true; } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error'); return false; } } -async function shareWithWebFile(src) { +async function shareWithWebFile(src, ticket) { + assertSharingOwner(ticket); if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return 'unavailable'; try { - var blob = await imageSourceToBlob(src); + var blob = await imageSourceToBlob(src, ticket); + assertSharingOwner(ticket); var file = new File([blob], 'clinical-visual.png', { type: blob.type || 'image/png' }); if (!navigator.canShare({ files: [file] })) return 'unavailable'; + assertSharingOwner(ticket); await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' }); + assertSharingOwner(ticket); return 'shared'; - } catch (e) { return isShareCancel(e) ? 'unavailable' : 'unavailable'; } + } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; + return 'unavailable'; + } } -async function downloadWithBrowser(src) { +async function downloadWithBrowser(src, ticket) { + assertSharingOwner(ticket); if (isMobileBrowser()) { if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info'); return; } - var blob = await imageSourceToBlob(src); + var blob = await imageSourceToBlob(src, ticket); + assertSharingOwner(ticket); var objectUrl = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = objectUrl; @@ -167,19 +219,29 @@ async function downloadWithBrowser(src) { setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000); } -async function downloadFromServer(url) { - var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' }); +async function downloadFromServer(url, ticket) { + assertSharingOwner(ticket); + var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin', signal: ticket.signal }); + assertSharingOwner(ticket); if (!response.ok) throw new Error('Image download failed'); var blob = await response.blob(); - var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png'; - if (await saveBlobWithNativeImageBridge(blob, name)) return; - if (await saveBlobWithCapacitorShare(blob, name)) return; - if (await shareBlobWithWebFile(blob, name)) return; + assertSharingOwner(ticket); + var name = sharingFilename('clinical-visual', 'png'); + var saved = await saveBlobWithNativeImageBridge(blob, name, ticket); + assertSharingOwner(ticket); + if (saved) return; + saved = await saveBlobWithCapacitorShare(blob, name, ticket); + assertSharingOwner(ticket); + if (saved) return; + saved = await shareBlobWithWebFile(blob, name, ticket); + assertSharingOwner(ticket); + if (saved) return; if (isMobileBrowser()) { if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error'); return; } - downloadBlobWithAnchor(blob, name); + assertSharingOwner(ticket); + downloadBlobWithAnchor(blob, name, ticket); } function authHeadersForDownload() { @@ -191,44 +253,67 @@ function authHeadersForDownload() { return clean; } -async function saveBlobWithNativeImageBridge(blob, name) { +async function saveBlobWithNativeImageBridge(blob, name, ticket) { + assertSharingOwner(ticket); if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false; try { - var base64 = await blobToBase64(blob); + var base64 = await blobToBase64(blob, ticket); + assertSharingOwner(ticket); var result = String(window.NativeFiles.saveImage(name, base64) || ''); + assertSharingOwner(ticket); if (result.indexOf('saved:') === 0) { if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success'); return true; } - } catch (e) {} + } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; + } return false; } -async function saveBlobWithCapacitorShare(blob, name) { +async function saveBlobWithCapacitorShare(blob, name, ticket) { + assertSharingOwner(ticket); var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null; if (!plugins || !plugins.Filesystem) return false; try { - var base64 = await blobToBase64(blob); + var base64 = await blobToBase64(blob, ticket); + assertSharingOwner(ticket); var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' }); + assertSharingOwner(ticket); if (plugins.Share && saved && saved.uri) { + assertSharingOwner(ticket); await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' }); + assertSharingOwner(ticket); } if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success'); return true; - } catch (e) { return false; } + } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; + return false; + } } -async function shareBlobWithWebFile(blob, name) { +async function shareBlobWithWebFile(blob, name, ticket) { + assertSharingOwner(ticket); if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false; try { var file = new File([blob], name, { type: blob.type || 'image/png' }); if (!navigator.canShare({ files: [file] })) return false; + assertSharingOwner(ticket); await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' }); + assertSharingOwner(ticket); return true; - } catch (e) { return false; } + } catch (e) { + assertSharingOwner(ticket); + if (e.name === 'AbortError') throw e; + return false; + } } -function downloadBlobWithAnchor(blob, name) { +function downloadBlobWithAnchor(blob, name, ticket) { + assertSharingOwner(ticket); var objectUrl = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = objectUrl; @@ -253,22 +338,30 @@ function isShareCancel(error) { return /cancel|abort|dismiss|user denied|share canceled/.test(message); } -async function imageSourceToBase64(src) { +async function imageSourceToBase64(src, ticket) { + assertSharingOwner(ticket); if (/^data:image\//i.test(src)) return src.split(',')[1] || ''; - var blob = await imageSourceToBlob(src); - return blobToBase64(blob); + var blob = await imageSourceToBlob(src, ticket); + assertSharingOwner(ticket); + var base64 = await blobToBase64(blob, ticket); + assertSharingOwner(ticket); + return base64; } -async function blobToBase64(blob) { +async function blobToBase64(blob, ticket) { + assertSharingOwner(ticket); return new Promise(function(resolve, reject) { var reader = new FileReader(); - reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); }; + reader.onload = function() { + try { assertSharingOwner(ticket); resolve(String(reader.result || '').split(',')[1] || ''); } catch (e) { reject(e); } + }; reader.onerror = function() { reject(reader.error || new Error('Could not read image')); }; reader.readAsDataURL(blob); }); } -async function imageSourceToBlob(src) { +async function imageSourceToBlob(src, ticket) { + assertSharingOwner(ticket); if (/^data:image\//i.test(src)) { var parts = src.split(','); var meta = parts[0] || 'data:image/png;base64'; @@ -277,9 +370,12 @@ async function imageSourceToBlob(src) { for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new Blob([bytes], { type: (meta.match(/data:([^;]+)/) || [])[1] || 'image/png' }); } - var response = await fetch(src, { credentials: 'omit' }); + var response = await fetch(src, { credentials: 'omit', signal: ticket.signal }); + assertSharingOwner(ticket); if (!response.ok) throw new Error('Image download failed'); - return response.blob(); + var blob = await response.blob(); + assertSharingOwner(ticket); + return blob; } export function buildContextualImagePrompt(request, lastAnswer) { diff --git a/public/js/assistant/sharing.js b/public/js/assistant/sharing.js new file mode 100644 index 0000000..e60c08b --- /dev/null +++ b/public/js/assistant/sharing.js @@ -0,0 +1,24 @@ +// Keep the original document boundary as well as its ticket across fallbacks. +export function captureSharingOwner() { + var boundary = window.AccountBoundary; + var owner = boundary && typeof boundary.capture === 'function' && typeof boundary.signal === 'function' + ? { boundary: boundary, ticket: boundary.capture(), signal: boundary.signal() } : null; + assertSharingOwner(owner); + return owner; +} + +export function validSharingOwner(owner) { + return !!(owner && owner.ticket && owner.boundary === window.AccountBoundary && + owner.signal && !owner.signal.aborted && typeof owner.boundary.valid === 'function' && owner.boundary.valid(owner.ticket)); +} + +export function assertSharingOwner(owner) { + if (!validSharingOwner(owner)) { + throw owner && typeof owner.boundary.error === 'function' ? owner.boundary.error() : new DOMException('Account unavailable', 'AbortError'); + } +} + +export function sharingFilename(prefix, extension) { + // An already-admitted OS write can finish after freeze; never reuse B's path. + return prefix + '-' + window.crypto.randomUUID() + '.' + extension; +} diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js index 566d9f2..0b5d8b0 100644 --- a/test/assistant-saved-tables.test.js +++ b/test/assistant-saved-tables.test.js @@ -13,8 +13,10 @@ const table = '| Item | Value (mg/kg) | Notes | Sources |\n| :--- | ---: | :---: const collapse = text => text.replace(/\s+/g, ' ').trim(); function ui(t, parser = marked) { - const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test' }); + const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); const window = dom.window; + window.eval(read('public/js/accountBoundary.js')); + assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true); const style = window.document.createElement('style'); style.textContent = read('public/css/assistant.css'); window.document.head.appendChild(style); @@ -29,7 +31,7 @@ function ui(t, parser = marked) { saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(savedChatPayload(body)))); return { success: true }; } }; vm.createContext(context); - for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/export.js', 'clinicalAssistant.js']) { + for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'assistant/export.js', 'clinicalAssistant.js']) { vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); } t.after(() => window.close()); diff --git a/test/assistant-sharing-boundary.test.js b/test/assistant-sharing-boundary.test.js new file mode 100644 index 0000000..f0a87a4 --- /dev/null +++ b/test/assistant-sharing-boundary.test.js @@ -0,0 +1,297 @@ +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 { JSDOM } = require('jsdom'); +const read = file => fs.readFileSync(path.join(__dirname, '..', 'public/js', file), 'utf8'); +const tick = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; }; +const image = 'data:image/png;base64,c3ludGhldGlj'; +const remote = 'https://example.test/synthetic.png'; + +function ui(t, { native = false, inline = true } = {}) { + const dom = new JSDOM('
unrelated feature
', { url: 'https://example.test', runScripts: 'outside-only' }); + const w = dom.window; + // Real boundary code, synthetic storage/identity, no auth/network services. + w.eval(read('accountBoundary.js')); + assert.equal(w.AccountBoundary.enter({ id: 'synthetic-A' }, true), true); + const calls = { native: [], writes: [], shares: [], web: [], anchors: [], toasts: [], prints: [], fetches: [], urls: [] }; + const timers = []; + w.matchMedia = () => ({ matches: inline }); + w.showToast = (...args) => calls.toasts.push(args); + w.print = () => calls.prints.push('browser'); + if (native) w.Capacitor = { isNativePlatform: () => true, Plugins: {} }; + w.URL.createObjectURL = () => { calls.urls.push('create'); return 'blob:synthetic'; }; + w.URL.revokeObjectURL = () => calls.urls.push('revoke'); + w.HTMLAnchorElement.prototype.click = function() { calls.anchors.push(this.download); }; + w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return { ok: true, blob: async () => new w.Blob(['synthetic'], { type: 'image/png' }) }; }; + w.setTimeout = fn => { timers.push(fn); return timers.length; }; + w.clearTimeout = id => { timers[id - 1] = () => {}; }; + for (const file of ['assistant/citations.js', 'assistant/sharing.js', 'assistant/images.js', 'assistant/export.js']) { + if (file.endsWith('sharing.js') && !fs.existsSync(path.join(__dirname, '..', 'public/js', file))) continue; + vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext()); + } + const store = w.createAssistantImageStore(); + const exporter = w.createAssistantExporter({ showToast: w.showToast }); + function freezeAndReplace() { + const original = w.AccountBoundary; + original.freeze(); + // A real document cannot re-enter. A replacement object models a fresh B + // becoming visible to stale callbacks; they must retain A's boundary. + const b = new JSDOM('', { url: 'https://example.test', runScripts: 'outside-only' }); + b.window.eval(read('accountBoundary.js')); + assert.equal(b.window.AccountBoundary.enter({ id: 'synthetic-B' }, true), true); + w.AccountBoundary = b.window.AccountBoundary; + t.after(() => b.window.close()); + } + function download(src = image, url = '') { + const html = store.renderGeneratedImage(src, 'synthetic', url); + return store.downloadImage(html.match(/data-assistant-download-image="([^"]+)"/)[1]); + } + function exported(answer = 'Synthetic answer A') { + exporter.exportAnswerPdf({ lastAnswer: answer, lastGeneratedImageSrc: image }); + return w.document.querySelector('#assistant-export-modal'); + } + function plugins() { + return { Filesystem: { writeFile: async args => { calls.writes.push(args); return { uri: 'file://' + args.path }; } }, + Share: { share: async args => { calls.shares.push(args); } } }; + } + t.after(() => w.close()); + return { w, calls, timers, store, exporter, freezeAndReplace, download, exported, plugins }; +} + +for (const stage of ['fetch', 'blob', 'reader']) { + for (const server of [false, true]) { + test(`image ${server ? 'server' : 'legacy'} ${stage} continuation rejects A -> freeze -> B`, async t => { + const app = ui(t, { native: true }); + const pending = deferred(); + app.w.Capacitor.Plugins = app.plugins(); + app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return 'saved:ok'; } }; + if (stage === 'fetch') app.w.fetch = () => pending.promise; + if (stage === 'blob') app.w.fetch = async () => ({ ok: true, blob: () => pending.promise }); + if (stage === 'reader') app.w.FileReader = class { readAsDataURL() { pending.promise.then(() => { this.result = image; this.onload(); }); } }; + const job = app.download(remote, server ? '/api/synthetic-download' : ''); + await tick(); + app.freezeAndReplace(); + pending.resolve(stage === 'fetch' ? { ok: true, blob: async () => new app.w.Blob(['synthetic']) } : new app.w.Blob(['synthetic'])); + await job; + assert.deepEqual(app.calls.native, []); + assert.deepEqual(app.calls.writes, []); + assert.deepEqual(app.calls.shares, []); + assert.deepEqual(app.calls.toasts, []); + }); + } +} + +test('NativeFiles failure cannot recapture B for Share-only fallback', async t => { + const app = ui(t, { native: true }); + app.w.NativeFiles = { saveImage() { app.freezeAndReplace(); throw new Error('bridge unavailable'); } }; + app.w.Capacitor.Plugins = { Share: app.plugins().Share }; + await app.download(remote); + assert.deepEqual(app.calls.shares, []); + assert.deepEqual(app.calls.toasts, []); +}); + +for (const server of [false, true]) { + test(`late ${server ? 'server' : 'legacy'} Filesystem write cannot share/toast or overwrite B's filename`, async t => { + const app = ui(t, { native: true }); + const pending = deferred(); + app.w.Capacitor.Plugins = app.plugins(); + const paths = []; + app.w.Capacitor.Plugins.Filesystem.writeFile = args => { paths.push(args.path); return paths.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); }; + const job = app.download(image, server ? '/api/synthetic-download' : ''); + for (let i = 0; i < 100 && !paths.length; i++) await tick(); + assert.equal(paths.length, 1, 'first write was admitted'); + app.freezeAndReplace(); + await app.download(image, server ? '/api/synthetic-download' : ''); + const admitted = app.calls.shares.length; + app.calls.toasts.length = 0; + pending.resolve({ uri: 'file://' + paths[0] }); + await job; + assert.equal(app.calls.shares.length, admitted); + assert.deepEqual(app.calls.toasts, []); + assert.notEqual(paths[0], paths[1]); + }); +} + +for (const mode of ['web conversion', 'web reject', 'browser conversion', 'share-only reject']) { + test(`${mode} cannot fall through to effects/toasts under B`, async t => { + const app = ui(t, { native: mode === 'share-only reject' }); + const pending = deferred(); + if (mode.startsWith('web')) { + app.w.navigator.canShare = () => true; + app.w.navigator.share = args => { app.calls.web.push(args); return mode === 'web reject' ? pending.promise : Promise.resolve(); }; + } + if (mode.endsWith('conversion')) app.w.fetch = async () => ({ ok: true, blob: () => pending.promise }); + if (mode === 'share-only reject') app.w.Capacitor.Plugins.Share = { share: args => { app.calls.shares.push(args); return pending.promise; } }; + const job = app.download(remote); + await tick(); + app.freezeAndReplace(); + if (mode.endsWith('reject')) pending.reject(new Error('capability rejected')); + else pending.resolve(new app.w.Blob(['synthetic'])); + await job; + assert.deepEqual(app.calls.anchors, []); + assert.deepEqual(app.calls.urls, []); + assert.deepEqual(app.calls.toasts, []); + assert.equal(app.calls.web.length, mode === 'web reject' ? 1 : 0); + assert.equal(app.calls.shares.length, mode === 'share-only reject' ? 1 : 0); + }); +} + +test('an active-owner policy AbortError is not a capability fallback', async t => { + const app = ui(t, { native: true }); + app.w.NativeFiles = { saveImage() { throw new app.w.DOMException('Synthetic policy abort', 'AbortError'); } }; + app.w.Capacitor.Plugins = app.plugins(); + await app.download(image); + assert.deepEqual(app.calls.writes, []); + assert.deepEqual(app.calls.toasts, []); +}); + +test('download and export fail closed when boundary is absent, incomplete or inactive', async t => { + for (const boundary of [undefined, {}, { capture: () => 'A' }]) { + const app = ui(t, { native: true }); + app.w.AccountBoundary = boundary; + app.w.Capacitor.Plugins = app.plugins(); + await app.download(); + assert.equal(app.exported(), null); + assert.deepEqual(app.calls.writes, []); + assert.deepEqual(app.calls.toasts, []); + } + const app = ui(t, { native: true }); + app.w.AccountBoundary.freeze(); + await app.download(); + assert.equal(app.exported(), null); +}); + +for (const mode of ['native', 'filesystem', 'web', 'browser', 'share-only', 'native fallback', 'server native', 'server filesystem', 'server web', 'server browser']) { + test(`same-owner image succeeds: ${mode}`, async t => { + const app = ui(t, { native: /native|filesystem|share-only/.test(mode) }); + if (/native/.test(mode)) app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return mode === 'native fallback' ? 'error:unavailable' : 'saved:ok'; } }; + if (/filesystem|fallback/.test(mode)) app.w.Capacitor.Plugins = app.plugins(); + if (mode === 'share-only') app.w.Capacitor.Plugins = { Share: app.plugins().Share }; + if (/web/.test(mode)) { app.w.navigator.canShare = () => true; app.w.navigator.share = async args => { app.calls.web.push(args); }; } + await app.download(mode === 'share-only' ? remote : image, mode.startsWith('server') ? '/api/synthetic-download' : ''); + assert.equal(app.calls.native.length + app.calls.shares.length + app.calls.web.length + app.calls.anchors.length, mode === 'native fallback' ? 2 : 1); + if (mode.startsWith('server')) { + assert.equal(app.calls.fetches[0].options.credentials, 'same-origin'); + assert.equal(app.calls.fetches[0].options.signal, app.w.AccountBoundary.signal()); + } + }); +} + +test('inline Print retains original export owner and closes only its preview on freeze', async t => { + const app = ui(t, { native: true }); + app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) }; + app.w.Capacitor.Plugins = app.plugins(); + const modal = app.exported(); + const print = modal.querySelector('#assistant-export-print'); + app.freezeAndReplace(); + assert.equal(modal.isConnected, false); + const newer = app.exported('Synthetic answer B'); + print.click(); + await tick(); + assert.deepEqual(app.calls.prints, []); + assert.deepEqual(app.calls.writes, []); + assert.deepEqual(app.calls.toasts, []); + assert.equal(newer.isConnected, true); + assert.ok(app.w.document.querySelector('#unrelated')); +}); + +test('late inline Filesystem completion has no Share, print or toast and uses a distinct filename', async t => { + const app = ui(t, { native: true }); + const pending = deferred(); + app.w.Capacitor.Plugins = app.plugins(); + app.w.Capacitor.Plugins.Filesystem.writeFile = args => { app.calls.writes.push(args); return app.calls.writes.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); }; + const old = app.exported(); + old.querySelector('#assistant-export-print').click(); + await tick(); + app.freezeAndReplace(); + assert.equal(old.isConnected, false); + app.exported('Synthetic answer B').querySelector('#assistant-export-print').click(); + await tick(); + const shares = app.calls.shares.length; + app.calls.toasts.length = 0; + pending.resolve({ uri: 'file://' + app.calls.writes[0].path }); + await tick(); + assert.equal(app.calls.shares.length, shares); + assert.deepEqual(app.calls.prints, []); + assert.deepEqual(app.calls.toasts, []); + assert.notEqual(app.calls.writes[0].path, app.calls.writes[1].path); +}); + +for (const stage of ['native rejection', 'share rejection', 'native return', 'browser fallback']) { + test(`inline ${stage} checks original owner before fallbacks`, async t => { + const app = ui(t, { native: true }); + app.w.Capacitor.Plugins = app.plugins(); + if (stage.startsWith('native')) app.w.NativePrint = { printHtml() { app.freezeAndReplace(); if (stage.endsWith('rejection')) throw new Error('unavailable'); } }; + if (stage === 'share rejection') app.w.Capacitor.Plugins.Share.share = async () => { app.freezeAndReplace(); throw new Error('unavailable'); }; + if (stage === 'browser fallback') { + app.w.Capacitor.Plugins = {}; + // The click handler yields on its first helper even if there is no bridge. + } + const modal = app.exported(); + modal.querySelector('#assistant-export-print').click(); + if (stage === 'browser fallback') app.freezeAndReplace(); + await tick(); + assert.deepEqual(app.calls.prints, []); + assert.deepEqual(app.calls.toasts, []); + assert.equal(app.calls.writes.length, stage === 'share rejection' ? 1 : 0); + assert.equal(modal.isConnected, false); + }); +} + +for (const mode of ['native', 'filesystem', 'browser']) { + test(`same-owner inline export succeeds: ${mode}`, async t => { + const app = ui(t, { native: true }); + if (mode === 'native') app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) }; + if (mode === 'filesystem') app.w.Capacitor.Plugins = app.plugins(); + app.exported().querySelector('#assistant-export-print').click(); + await tick(); + assert.equal(app.calls.prints.length + app.calls.shares.length, 1); + const encoded = mode === 'native' ? app.calls.prints[0][1] : app.calls.writes[0]?.data; + if (encoded) { + const html = Buffer.from(encoded, 'base64').toString(); + assert.match(html, /Synthetic answer A/); + assert.ok(html.includes(image)); + } + }); +} + +for (const frozen of [false, true]) { + test(`desktop export ${frozen ? 'closes on freeze without delayed/click print' : 'prints on timer and click'}`, t => { + const app = ui(t, { inline: false }); + const popup = new JSDOM('', { url: 'https://example.test' }); + const p = popup.window; + let closed = 0; + p.focus = () => {}; + p.print = () => app.calls.prints.push('popup'); + const dispose = p.close.bind(p); + t.after(dispose); + p.close = () => { closed++; }; // retain detached callbacks to exercise them + app.w.open = () => p; + app.exported(); + const print = p.document.querySelector('#assistant-export-print'); + if (frozen) app.freezeAndReplace(); + for (const timer of app.timers) timer(); + print.click(); + assert.equal(app.calls.prints.length, frozen ? 0 : 2); + assert.equal(closed > 0, frozen); + assert.ok(app.w.document.querySelector('#unrelated')); + }); +} + +test('image preview closes only its owned modal on boundary closure', t => { + const app = ui(t); + const unrelated = app.w.document.createElement('div'); + unrelated.className = 'assistant-image-modal'; + app.w.document.body.append(unrelated); + app.store.renderGeneratedImage(image); + app.store.openImagePreview('img-1'); + assert.equal(unrelated.isConnected, true); + const owned = app.w.document.querySelector('.assistant-image-modal[role="dialog"]'); + assert.ok(owned); + app.freezeAndReplace(); + assert.equal(owned.isConnected, false); + assert.equal(unrelated.isConnected, true); +}); diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js index f97c4da..1753e84 100644 --- a/test/clinical-release-integration.test.js +++ b/test/clinical-release-integration.test.js @@ -37,6 +37,8 @@ test('actual markup loads the existing local DOMPurify distribution through its 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('
' + read('public/components/admin.html') + '
' + read('public/components/assistant.html') + '
', { 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; diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index 068dd12..240a68b 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -18,8 +18,12 @@ let moduleId = 0; async function browser(t, module, handler) { const component = module.startsWith('admin') ? 'admin' : 'assistant'; - const dom = new JSDOM('
' + read('public/components/' + component + '.html') + '
', { url: 'https://synthetic.invalid' }); + const dom = new JSDOM('
' + read('public/components/' + component + '.html') + '
', { url: 'https://synthetic.invalid', runScripts: 'outside-only' }); const { window } = dom; + if (component === 'assistant') { + window.eval(read('public/js/accountBoundary.js')); + assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true); + } const calls = []; const toasts = []; const fetch = async (url, options = {}) => { calls.push({ url, options, body: options.body && JSON.parse(options.body) });