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 ExportPreparing 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) });