Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
99 lines
4.1 KiB
JavaScript
99 lines
4.1 KiB
JavaScript
// ============================================================
|
|
// Global fetch interceptor for auth failures
|
|
// ============================================================
|
|
// When the server responds 401 to any /api/ request, the user's
|
|
// session has been revoked or expired. Previously this would leave
|
|
// the app UI on screen silently because each fetch handled its own
|
|
// errors. Now we detect it once, clear local state, and bounce to
|
|
// the login screen.
|
|
// ============================================================
|
|
|
|
(function() {
|
|
if (window.__fetchAuthIntercepted) return;
|
|
window.__fetchAuthIntercepted = true;
|
|
|
|
var _fetch = window.fetch.bind(window);
|
|
var handlingLogout = false;
|
|
|
|
// Cross-tab sync: when one tab logs out, all other open tabs drop
|
|
// their UI within milliseconds instead of waiting for their next
|
|
// failed fetch. Uses the same BroadcastChannel name across tabs.
|
|
var bc = null;
|
|
try { bc = new BroadcastChannel('pedscribe-auth'); } catch (e) { bc = null; }
|
|
window.__authBroadcast = bc;
|
|
if (bc) {
|
|
bc.onmessage = function(ev) {
|
|
if (ev && ev.data && ev.data.type === 'logout') {
|
|
handleAuthFailure(null, 'You signed out in another tab.');
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleAuthFailure(status, reason) {
|
|
if (handlingLogout) return;
|
|
handlingLogout = true;
|
|
try {
|
|
// Clear any cached token + user info
|
|
window.AUTH_TOKEN = null;
|
|
window.CURRENT_USER = null;
|
|
if (window.SecureStorage) {
|
|
try { window.SecureStorage.remove('ped_scribe_token'); } catch(e) {}
|
|
try { window.SecureStorage.remove('ped_scribe_user'); } catch(e) {}
|
|
try { window.SecureStorage.remove('ped_session_id'); } catch(e) {}
|
|
}
|
|
try {
|
|
localStorage.removeItem('ped_scribe_token');
|
|
localStorage.removeItem('ped_scribe_user');
|
|
localStorage.removeItem('ped_session_id');
|
|
} catch(e) {}
|
|
|
|
// Brief user-visible notice before the reload swap
|
|
try {
|
|
if (typeof window.showToast === 'function') {
|
|
window.showToast(reason || 'Your session has ended. Please sign in again.', 'info');
|
|
}
|
|
} catch(e) {}
|
|
|
|
// Reload — the boot flow in auth.js will call /api/auth/me, get a
|
|
// fresh 401 (no cookie / no token), and show the login screen.
|
|
setTimeout(function() { window.location.reload(); }, 800);
|
|
} catch (e) {
|
|
// Last resort
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
window.fetch = function(input, init) {
|
|
return _fetch(input, init).then(function(resp) {
|
|
// Only act on same-origin /api/ calls; don't interfere with login
|
|
// or logout endpoints themselves (they're allowed to return 401).
|
|
try {
|
|
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
var isApiCall = url.indexOf('/api/') !== -1;
|
|
var isAuthEndpoint = url.indexOf('/api/auth/login') !== -1
|
|
|| url.indexOf('/api/auth/register') !== -1
|
|
|| url.indexOf('/api/auth/logout') !== -1
|
|
|| url.indexOf('/api/auth/forgot-password') !== -1
|
|
|| url.indexOf('/api/auth/reset-password') !== -1
|
|
|| url.indexOf('/api/auth/verify-email') !== -1
|
|
|| url.indexOf('/api/auth/resend-verification') !== -1
|
|
|| url.indexOf('/api/auth/oidc') !== -1
|
|
|| url.indexOf('/api/auth/me') !== -1
|
|
|| url.indexOf('/api/auth/2fa') !== -1;
|
|
if (resp.status === 401 && isApiCall && !isAuthEndpoint) {
|
|
// Only treat as global session failure if the app thinks the user
|
|
// was logged in (AUTH_TOKEN set OR main-app visible). Avoid
|
|
// flashing the login screen on public-endpoint 401s during the
|
|
// pre-login phase.
|
|
var authed = !!window.AUTH_TOKEN
|
|
|| document.documentElement.classList.contains('has-session')
|
|
|| (document.getElementById('main-app') && document.getElementById('main-app').style.display === 'block');
|
|
if (authed) {
|
|
handleAuthFailure(resp.status, 'Your session was ended by another device. Please sign in again.');
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
return resp;
|
|
});
|
|
};
|
|
})();
|