pediatric-ai-scribe-v3/public/js/accountBoundary.js

218 lines
9.5 KiB
JavaScript

// One verified owner per document. Never unlock: a new account needs a new JS realm.
(function() {
var KEY = 'ped_account_boundary_v1';
var owner = null, generation = null, locked = false, revision = 0;
var controller = new AbortController();
var channel = null;
var storageFailed = false;
var signInRequired = false;
var SIGN_IN_KEY = 'ped_signin_required';
function needsSignIn() {
try { return sessionStorage.getItem(SIGN_IN_KEY) === '1'; }
catch (e) { return true; }
}
function recoverSignIn() {
signInRequired = true;
freeze();
document.querySelector('#account-recovery p').textContent =
'Your saved credentials no longer match this session. Sign in again in a fresh page.';
document.querySelector('#account-recovery button').textContent = 'Sign in again';
}
function read() {
try { return JSON.parse(localStorage.getItem(KEY) || 'null'); }
catch (e) { storageFailed = true; return null; }
}
function publish(next) {
try {
var value = JSON.stringify(next);
localStorage.setItem(KEY, value);
if (localStorage.getItem(KEY) !== value) throw new Error('Account isolation write not confirmed');
storageFailed = false;
}
catch (e) { storageFailed = true; freeze(); throw new Error('Account isolation storage unavailable'); }
try { if (channel) channel.postMessage(next); } catch (e) {}
return next;
}
function fresh(nextOwner) {
return { owner: nextOwner, generation: crypto.randomUUID(), signedOut: !nextOwner };
}
function abortError() { return new DOMException('Account changed; reload required', 'AbortError'); }
function reload() {
// A failed publication is only realm-local until a durable signed-out latch
// exists. Never discard that failure by navigating back to surviving auth.
if (storageFailed) {
freeze();
try { publish(fresh(null)); }
catch (e) {
document.querySelector('#account-recovery p').textContent =
'Cannot safely reload: session storage is unavailable. Restore storage access, then retry Reload.';
return;
}
}
if (signInRequired) {
// Realm-local recovery must not erase a newer sibling's owner/credentials.
try {
sessionStorage.setItem(SIGN_IN_KEY, '1');
if (sessionStorage.getItem(SIGN_IN_KEY) !== '1') throw new Error('Sign-in recovery write not confirmed');
} catch (e) {
document.querySelector('#account-recovery p').textContent =
'Cannot safely reload: session storage is unavailable. Restore storage access, then retry Sign in again.';
return;
}
}
try { window.location.reload(); } catch (e) { /* Recovery stays available. */ }
}
function freeze() {
if (locked) return;
locked = true;
revision++;
controller.abort();
// CSS also hides late modal/async DOM insertions outside main-app.
document.documentElement.classList.add('account-transition');
Array.from(document.body.children).forEach(function(el) { el.inert = true; });
var notice = document.createElement('section');
notice.id = 'account-recovery';
notice.setAttribute('role', 'alert');
var text = document.createElement('p');
text.textContent = 'Your account session changed. Reload to continue safely.';
var button = document.createElement('button');
button.textContent = 'Reload';
button.addEventListener('click', reload);
notice.append(text, button);
document.body.appendChild(notice);
button.focus();
window.dispatchEvent(new Event('account-boundary'));
}
function current() {
if (locked) return false;
var shared = read();
if (owner && (!shared || shared.signedOut || shared.owner !== owner || shared.generation !== generation)) {
freeze();
reload();
}
return !locked && !storageFailed && !!owner;
}
function capture() { return current() ? owner : null; }
function valid(ticket) { return !!ticket && ticket === owner && current(); }
function receive(message) {
var latest = read();
// Ignore queued events from A after B has already published its session.
if (!message || !latest || message.generation !== latest.generation) return;
if (owner && (message.signedOut || message.owner !== owner || message.generation !== generation)) {
freeze();
reload(); // Sibling events must never delete another tab's newly persisted native credentials.
}
}
try { channel = new BroadcastChannel('pedscribe-auth'); channel.onmessage = function(e) { receive(e.data); }; } catch (e) {}
window.addEventListener('storage', function(e) {
if (e.key !== KEY) return;
if (!e.newValue) { if (owner) { freeze(); reload(); } return; }
try { receive(JSON.parse(e.newValue)); } catch (err) {}
});
window.addEventListener('pageshow', function(e) {
if (e.persisted) { freeze(); reload(); }
else if (owner) current();
});
// Hide before a BFCache snapshot is taken, not merely after it is restored.
window.addEventListener('pagehide', freeze);
// Preserve unowned legacy clinical data, but never read or adopt it.
// Clinical callers use storageKey() only after a verified owner enters.
window.AccountBoundary = {
key: KEY, read: read, freeze: freeze, reload: reload, capture: capture, valid: valid,
recoverSignIn: recoverSignIn, needsSignIn: needsSignIn,
completeSignIn: function() { sessionStorage.removeItem(SIGN_IN_KEY); },
active: current, blocked: function() { return locked; }, error: abortError,
revision: function() { return revision; }, signal: function() { return controller.signal; },
signedOut: function() { var state = read(); return storageFailed || !!(state && state.signedOut); },
startLogin: function() {
if (locked) throw abortError();
if (owner) freeze();
publish(fresh(null)); // Freeze siblings before a login response can replace the shared cookie.
return revision;
},
end: function() {
freeze();
var published = false;
try { publish(fresh(null)); published = true; } catch (e) { /* reload() retries the durable latch. */ }
window.AUTH_TOKEN = null;
window.CURRENT_USER = null;
return published;
},
enter: function(user, explicit) {
if (!user || user.id == null || storageFailed) { freeze(); return false; }
var nextOwner = String(user.id);
if (locked || (owner && owner !== nextOwner)) { freeze(); return false; }
if (!explicit && (this.signedOut() || needsSignIn())) return false;
var state = read();
if (storageFailed) { freeze(); return false; }
if (!explicit && state && state.owner !== nextOwner) { recoverSignIn(); return false; }
if (explicit || !state) state = publish(fresh(nextOwner));
owner = nextOwner; generation = state.generation;
window.dispatchEvent(new Event('account-ready'));
return true;
},
// Successful replacement login persists credentials first, but cannot enter this document.
publishLogin: function(user) { publish(fresh(String(user.id))); },
storageKey: function(key) {
if (!current()) throw abortError();
return key + ':owner:' + encodeURIComponent(owner);
}
};
// All actual recorder callers share these platform boundaries, including
// Notes, ED, the assistant and recorders constructed during pause/resume.
var recorders = new Set(), streams = new Set(), recognizers = new Set();
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
var getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = function(constraints) {
var ticket = capture();
if (!ticket) return Promise.reject(abortError());
return getUserMedia(constraints).then(function(stream) {
if (!valid(ticket)) { stream.getTracks().forEach(function(t) { t.stop(); }); throw abortError(); }
streams.add(stream);
return stream;
});
};
}
if (window.MediaRecorder) {
var startRecording = MediaRecorder.prototype.start;
MediaRecorder.prototype.start = function() {
if (!current()) throw abortError();
recorders.add(this);
var recorder = this;
this.addEventListener('stop', function() { recorders.delete(recorder); streams.delete(recorder.stream); }, { once: true });
['stop', 'dataavailable'].forEach(function(type) {
recorder.addEventListener(type, function(e) {
if (!current()) e.stopImmediatePropagation();
}, { capture: true });
});
return startRecording.apply(this, arguments);
};
}
[window.SpeechRecognition, window.webkitSpeechRecognition].filter(function(value, i, all) {
return value && all.indexOf(value) === i;
}).forEach(function(Recognition) {
var start = Recognition.prototype.start;
Recognition.prototype.start = function() {
if (!current()) throw abortError();
recognizers.add(this);
return start.apply(this, arguments);
};
});
window.addEventListener('account-boundary', function() {
recognizers.forEach(function(rec) {
rec.onresult = rec.onend = rec.onerror = null;
try { rec.abort(); } catch (e) {}
});
recorders.forEach(function(rec) {
rec.ondataavailable = rec.onstop = rec.onerror = null;
try { if (rec.state !== 'inactive') rec.stop(); } catch (e) {}
});
streams.forEach(function(stream) { stream.getTracks().forEach(function(track) { track.stop(); }); });
recorders.clear(); streams.clear(); recognizers.clear();
if (window.nativeStopRecordingService) window.nativeStopRecordingService();
if (window.nativeKeepAwake) window.nativeKeepAwake(false);
});
})();