revert: remove the signed-out assistant preview
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Removed at the owner's request, entirely rather than switched off: the route's allow-list, anonymous identity and flag lookup; the client's entry path, the authFetch exception that let four endpoints out without an account, and the workspace guard; the CSS, the in-page note, the admin flag and its save/load; the test file and the assertions elsewhere that pinned it. Both settings rows are deleted from app_settings. Two things were checked rather than assumed on the way out. Removing the anonymous identity collapsed every `if (!req.user.preview)` branch to its authenticated side, so image tools, audit logging and citation storage now run unconditionally — which is what they did before preview existed. And the route's gate went back to a bare router.use(authMiddleware), which on a /api mount gates every path below it in server.js; it is scoped to /clinical-assistant again, the guard test catches it either way. Verified after deploy: signed out, status, examples and chat all refuse with 401; signed in, chat still answers with 8 sources; extensions, encounters, documents and admin remain shut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
cd37a9d728
commit
fadf09bf4a
12 changed files with 15 additions and 297 deletions
|
|
@ -119,14 +119,6 @@
|
|||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;min-width:160px;">Signed-out preview:</label>
|
||||
<select id="cms-flag-assistant-preview" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Lets a visitor who is not signed in try the Clinical Assistant. They can ask questions and read the answer; everything else asks them to sign in. Nothing is saved to any account, because a preview visitor has no account.</p>
|
||||
<div>
|
||||
<button id="btn-save-flags" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save feature flags</button>
|
||||
<span id="cms-flags-status" role="status" style="font-size:12px;color:var(--g500);margin-left:8px;"></span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
<div class="assistant-drawer-backdrop" id="assistant-drawer-backdrop"></div>
|
||||
<aside class="assistant-history">
|
||||
<button id="btn-assistant-drawer-close" class="assistant-drawer-close" type="button" aria-label="Close saved chats"><i class="fas fa-xmark"></i></button>
|
||||
<p class="assistant-preview-note"><i class="fas fa-circle-info"></i> You are previewing. Sign in to save chats, export and generate images.</p>
|
||||
<!-- Brand, collapse and search share one row, as in the reference UIs.
|
||||
Three stacked full-width rows wasted the top of the menu. -->
|
||||
<div class="menu-head">
|
||||
|
|
|
|||
|
|
@ -1382,11 +1382,6 @@ button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select,
|
|||
|
||||
/* Signed-out preview: the assistant is usable, everything that needs an account
|
||||
is not shown rather than shown and refused. */
|
||||
body.assistant-preview .assistant-plus,
|
||||
body.assistant-preview .assistant-history .card,
|
||||
body.assistant-preview .assistant-rail-actions { display:none !important; }
|
||||
body.assistant-preview .assistant-preview-note { display:flex; }
|
||||
.assistant-preview-note { display:none; align-items:center; gap:8px; margin:0 0 10px; padding:8px 12px; border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:10px; font-size:12px; }
|
||||
|
||||
/* ── Account card ────────────────────────────────────────────────────────────
|
||||
Ends both menus identically. Settings, FAQ, Admin and Log out were unlabelled
|
||||
|
|
@ -1413,10 +1408,6 @@ body.assistant-preview .assistant-preview-note { display:flex; }
|
|||
.assistant-history .account-card { margin-top:auto; padding:8px 0; }
|
||||
/* Collapsed menus hide their contents; the card goes with them. */
|
||||
/* A preview visitor has no account to show. */
|
||||
body.assistant-preview .account-card { display:none; }
|
||||
/* The sign-in prompt a preview visitor meets is an invitation, not the
|
||||
compliance notice a clinician sees on first login. */
|
||||
body.assistant-preview .hipaa-notice { display:none; }
|
||||
|
||||
/* ── Search palette ──────────────────────────────────────────────────────────
|
||||
One component in both views; the view decides what it searches. */
|
||||
|
|
|
|||
|
|
@ -378,12 +378,6 @@ function adminTabActive() {
|
|||
var flagNextcloud = document.getElementById('cms-flag-nextcloud');
|
||||
if (flagReadAloud) flagReadAloud.value = cfg['feature.read_aloud'] !== undefined ? cfg['feature.read_aloud'] : 'true';
|
||||
if (flagNextcloud) flagNextcloud.value = cfg['feature.nextcloud'] !== undefined ? cfg['feature.nextcloud'] : 'true';
|
||||
var flagPreview = document.getElementById('cms-flag-assistant-preview');
|
||||
// Falls back to the key this setting used to live under, so the switch
|
||||
// shows the real state on an installation that set it before the move.
|
||||
if (flagPreview) flagPreview.value =
|
||||
cfg['feature.assistant_preview'] !== undefined ? cfg['feature.assistant_preview'] :
|
||||
(cfg['clinical_assistant.preview_enabled'] !== undefined ? cfg['clinical_assistant.preview_enabled'] : 'false');
|
||||
|
||||
// Auto-delete setting
|
||||
var autoDeleteDays = cfg['site.auto_delete_days'] || '7';
|
||||
|
|
@ -425,17 +419,12 @@ function adminTabActive() {
|
|||
var readAloud = document.getElementById('cms-flag-read-aloud').value;
|
||||
var nextcloud = document.getElementById('cms-flag-nextcloud').value;
|
||||
|
||||
var preview = (document.getElementById('cms-flag-assistant-preview') || {}).value;
|
||||
var status = document.getElementById('cms-flags-status');
|
||||
if (status) status.textContent = 'Saving...';
|
||||
|
||||
Promise.all([
|
||||
putConfig('feature.read_aloud', readAloud),
|
||||
putConfig('feature.nextcloud', nextcloud),
|
||||
// A feature flag, stored under feature.* like the others. The assistant
|
||||
// still reads the old clinical_assistant.preview_enabled key when this one
|
||||
// has never been set, so existing installations keep their setting.
|
||||
putConfig('feature.assistant_preview', preview === 'true' ? 'true' : 'false')
|
||||
putConfig('feature.nextcloud', nextcloud)
|
||||
]).then(function() {
|
||||
if (status) status.textContent = 'Saved.';
|
||||
showToast('Feature flags saved', 'success');
|
||||
|
|
|
|||
|
|
@ -176,13 +176,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (!pill) return;
|
||||
var wantsAssistant = pill.getAttribute('data-assistant-mode') === 'assistant';
|
||||
var onAssistant = window.location.pathname === '/assistant';
|
||||
// A preview visitor may try the assistant; the workspace is the product, so
|
||||
// reaching for it is where we ask them to sign in.
|
||||
if (document.body.classList.contains('assistant-preview') && !wantsAssistant) {
|
||||
var screen = document.getElementById('auth-screen');
|
||||
if (screen) screen.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
if (!onAssistant) {
|
||||
// In the app you ARE the workspace, so only the Assistant pill moves.
|
||||
// This used to set window.location, which reloads the document and kills
|
||||
|
|
|
|||
|
|
@ -176,37 +176,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// Auth screen is hidden by CSS default — only show it when there is no valid session
|
||||
function showAuthScreen() {
|
||||
if (boundary.blocked() || window.CURRENT_USER || !authScreen) return;
|
||||
// Admin-enabled preview: a signed-out visitor at /assistant may try the
|
||||
// assistant instead of meeting a wall. Everything else still needs an
|
||||
// account, and the server enforces that independently — this only decides
|
||||
// whether to show the login screen.
|
||||
// Any path, not only /assistant: with preview on, a visitor landing on the
|
||||
// root should meet the assistant, not a wall. enterPreview moves them there.
|
||||
fetch('/api/clinical-assistant/status', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.then(function(data) {
|
||||
if (data && data.success && data.preview) return enterPreview();
|
||||
authScreen.style.display = 'flex';
|
||||
})
|
||||
.catch(function() { authScreen.style.display = 'flex'; });
|
||||
}
|
||||
|
||||
// Raised by authFetch when a preview visitor reaches for something that needs
|
||||
// an account. The screen stays an overlay, so their chat is still underneath.
|
||||
document.addEventListener('preview-needs-account', function() {
|
||||
if (authScreen && document.body.classList.contains('assistant-preview')) authScreen.style.display = 'flex';
|
||||
});
|
||||
|
||||
// The auth screen is an overlay over an already-rendered app, so entering
|
||||
// preview is simply declining to raise it.
|
||||
function enterPreview() {
|
||||
document.body.classList.add('assistant-preview');
|
||||
if (authScreen) authScreen.style.display = 'none';
|
||||
// The app container starts hidden and enterApp is what reveals it. Declining
|
||||
// the overlay without doing the same left a hidden app under a hidden
|
||||
// overlay — /assistant in the address bar and nothing on the screen.
|
||||
if (mainApp) mainApp.style.display = 'block';
|
||||
if (typeof window.activateTab === 'function') window.activateTab('assistant');
|
||||
authScreen.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Check for SSO redirect (token is in httpOnly cookie) ──
|
||||
|
|
|
|||
|
|
@ -9,18 +9,6 @@ if (!window.__fetchAuthIntercepted) {
|
|||
'/api/auth/verify-email', '/api/auth/resend-verification',
|
||||
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status'
|
||||
]);
|
||||
// The signed-out preview. Same four paths the server allow-lists; nothing
|
||||
// else may leave the browser without an account. Until these were here the
|
||||
// status call was rejected before it was ever sent, so the server-side
|
||||
// preview could never be reached from the page.
|
||||
var previewPaths = new Set([
|
||||
'/api/clinical-assistant/status', '/api/clinical-assistant/examples',
|
||||
'/api/clinical-assistant/chat', '/api/clinical-assistant/chat/stream'
|
||||
]);
|
||||
function inPreview() { return document.body && document.body.classList.contains('assistant-preview'); }
|
||||
|
||||
// Narrow transition requests bypass the frozen clinical transport. Logout
|
||||
// headers are captured before freezing; its verification is cookie-only.
|
||||
boundary.logoutRequest = function(headers) {
|
||||
return rawFetch('/api/auth/logout', {
|
||||
method: 'POST', headers: headers, credentials: 'same-origin', keepalive: true
|
||||
|
|
@ -49,14 +37,6 @@ if (!window.__fetchAuthIntercepted) {
|
|||
if (boundary.blocked()) return Promise.reject(boundary.error());
|
||||
// No clinical module may preload a previous cookie's data on the login screen.
|
||||
if (!ticket && !authPaths.has(url.pathname) && url.pathname !== '/api/auth/me' && url.pathname !== '/api/models') {
|
||||
if (previewPaths.has(url.pathname)) return rawFetch(input, Object.assign({}, init, { credentials: 'same-origin' }));
|
||||
// A preview visitor reaching for anything else is exactly the moment to
|
||||
// ask them to sign in — one hook here, not a check on every button. Only
|
||||
// for something the visitor did, though: the page also fetches saved
|
||||
// chats and config in the background on load, and raising the sign-in
|
||||
// screen for those buried the assistant under it before a word was typed.
|
||||
var gesture = navigator.userActivation ? navigator.userActivation.isActive : false;
|
||||
if (inPreview() && gesture) document.dispatchEvent(new CustomEvent('preview-needs-account', { detail: { path: url.pathname } }));
|
||||
return Promise.reject(boundary.error());
|
||||
}
|
||||
var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register')
|
||||
|
|
|
|||
|
|
@ -45,60 +45,10 @@ var translateLanguageCache = clinicalTranslation.createLanguageCache();
|
|||
|
||||
var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts');
|
||||
|
||||
// ── Signed-out preview (admin opt-in, default OFF) ──────────────────────────
|
||||
// Deliberately narrow. A preview visitor may ask questions and read the answer;
|
||||
// they get NO identity, so nothing can be owned, saved, billed or addressed to
|
||||
// them. Everything else on this router still requires real authentication,
|
||||
// which is enforced by allow-listing exact paths rather than by pattern —
|
||||
// a new route is private unless someone adds it here on purpose.
|
||||
var PREVIEW_PATHS = new Set([
|
||||
'/clinical-assistant/status',
|
||||
'/clinical-assistant/examples',
|
||||
'/clinical-assistant/chat',
|
||||
'/clinical-assistant/chat/stream'
|
||||
]);
|
||||
var PREVIEW_USER = Object.freeze({ id: null, preview: true, role: 'preview' });
|
||||
|
||||
// It is a feature flag and now lives with the others under feature.*, which also
|
||||
// means an ordinary admin can still toggle it under ADMIN_LOCKDOWN — the
|
||||
// clinical_assistant.* prefix is locked, and burying a day-to-day switch behind
|
||||
// host access was not the intent.
|
||||
//
|
||||
// The old key is still honoured when the new one has never been written, so an
|
||||
// installation that enabled preview before the move keeps it enabled.
|
||||
async function previewEnabled() {
|
||||
var flag = await getSetting('feature.assistant_preview', '');
|
||||
if (String(flag) === '') flag = await getSetting('clinical_assistant.preview_enabled', 'false');
|
||||
return String(flag) === 'true';
|
||||
}
|
||||
|
||||
router.use(async function(req, res, next) {
|
||||
if (req.user) return next();
|
||||
if (!PREVIEW_PATHS.has(req.path)) return authMiddleware(req, res, next);
|
||||
try {
|
||||
if (!(await previewEnabled())) return authMiddleware(req, res, next);
|
||||
} catch (_) {
|
||||
return authMiddleware(req, res, next); // a lookup failure must not open the door
|
||||
}
|
||||
// An authenticated caller keeps their own identity; this only covers the
|
||||
// no-credential case, so preview can never downgrade a real session.
|
||||
var hasCredential = Boolean((req.headers.authorization || '').startsWith('Bearer ') || (req.cookies && req.cookies.ped_auth));
|
||||
if (hasCredential) return authMiddleware(req, res, next);
|
||||
req.user = PREVIEW_USER;
|
||||
next();
|
||||
});
|
||||
|
||||
// The middleware above may have assigned the anonymous preview identity.
|
||||
// authMiddleware knows nothing about that — it only looks for a token — so
|
||||
// calling it unconditionally here rejected exactly the requests preview exists
|
||||
// to allow, and the feature never worked at all. authMiddleware itself stays
|
||||
// strict: it is used everywhere else and must keep refusing anyone without a
|
||||
// credential. Only the preview identity, which this router assigns on
|
||||
// allow-listed paths when an admin has opted in, may pass.
|
||||
router.use(function(req, res, next) {
|
||||
if (req.user && req.user.preview) return next();
|
||||
return authMiddleware(req, res, next);
|
||||
});
|
||||
// Scoped to this router's own prefix. Mounted on /api, a bare
|
||||
// router.use(authMiddleware) gates every /api path, including routes owned by
|
||||
// routers mounted after it in server.js.
|
||||
router.use('/clinical-assistant', authMiddleware);
|
||||
|
||||
var MAX_SAVED_CHATS_PER_USER = 100;
|
||||
var MAX_SAVED_CHAT_TITLE = 160;
|
||||
|
|
@ -159,8 +109,6 @@ router.get('/clinical-assistant/status', async function(req, res) {
|
|||
conversationMeasure: budget.measure,
|
||||
translateProvider: translateProvider,
|
||||
showSources: await showSourcesEnabled(),
|
||||
previewEnabled: await previewEnabled(),
|
||||
preview: Boolean(req.user && req.user.preview),
|
||||
mcp: mcpHealth
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -357,12 +305,6 @@ router.post('/clinical-assistant/patient-takehome/email', async function(req, re
|
|||
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Could not send the email', code: e.code });
|
||||
}
|
||||
});
|
||||
function refusePreviewWrite(req, res) {
|
||||
if (!req.user || !req.user.preview) return false;
|
||||
res.status(401).json({ error: 'Create an account or sign in to use this', preview: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Citation quality tracking. Loaded on demand and allowed to be absent: it is
|
||||
// observation, not part of producing an answer, so it must never be able to
|
||||
// fail one — including in a harness that stubs this route's module graph.
|
||||
|
|
@ -379,7 +321,7 @@ function trackCitations(req, question, answer, sources) {
|
|||
if (!tracker) return;
|
||||
var result = tracker.record(answer, sources);
|
||||
// Not awaited: the clinician is waiting for this answer.
|
||||
if (!req.user.preview) tracker.store(req.user.id, question, result, sources);
|
||||
tracker.store(req.user.id, question, result, sources);
|
||||
}
|
||||
|
||||
router.post('/clinical-assistant/chat', async function(req, res) {
|
||||
|
|
@ -391,13 +333,13 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
var ai = await callAI(prepared.messages, assistantGenerationOptions({
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
tools: req.user.preview ? undefined : imageTool.tools,
|
||||
tools: imageTool.tools,
|
||||
maxTokens: 2600,
|
||||
images: prepared.images
|
||||
}));
|
||||
if (!req.user.preview) ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
|
||||
ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
|
||||
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
|
||||
if (!req.user.preview) ai = await dispatchImageRequestFallback(ai, prepared, req);
|
||||
ai = await dispatchImageRequestFallback(ai, prepared, req);
|
||||
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
|
||||
messages: prepared.messages,
|
||||
chatModel: prepared.chatModel,
|
||||
|
|
@ -407,7 +349,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
var answer = finalized.answer;
|
||||
ai = finalized.ai;
|
||||
|
||||
if (!req.user.preview) logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
|
||||
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
|
||||
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
|
||||
});
|
||||
|
||||
|
|
@ -465,14 +407,14 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
|||
var ai = await callAIStream(prepared.messages, assistantGenerationOptions({
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
tools: req.user.preview ? undefined : imageTool.tools,
|
||||
tools: imageTool.tools,
|
||||
maxTokens: 2600,
|
||||
images: prepared.images
|
||||
}), function(delta) {
|
||||
sendEvent('token', { token: delta });
|
||||
});
|
||||
|
||||
if (!req.user.preview) {
|
||||
{
|
||||
ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
|
||||
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
|
||||
ai = await dispatchImageRequestFallback(ai, prepared, req);
|
||||
|
|
@ -489,7 +431,7 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
|||
var answer = finalized.answer;
|
||||
ai = finalized.ai;
|
||||
|
||||
if (!req.user.preview) logger.audit(req.user.id, 'clinical_assistant_streaming_query', 'Clinical assistant streaming query', req, {
|
||||
logger.audit(req.user.id, 'clinical_assistant_streaming_query', 'Clinical assistant streaming query', req, {
|
||||
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
|
||||
});
|
||||
// Quality tracking: does every citation the model wrote point at a source
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
// Signed-out preview is an admin opt-in that widens who can reach the assistant,
|
||||
// so the boundaries matter more than the feature.
|
||||
test('preview is off unless an admin turns it on', () => {
|
||||
const route = read('src/routes/clinicalAssistant.js');
|
||||
assert.match(route, /getSetting\('clinical_assistant\.preview_enabled', 'false'\)/,
|
||||
'default is off');
|
||||
assert.match(route, /=== 'true'/, 'and only the exact string enables it');
|
||||
const admin = read('src/routes/adminConfig.js');
|
||||
assert.match(admin, /clinical_assistant\.preview_enabled' && !\['true', 'false'\]/,
|
||||
'the server refuses a non-boolean');
|
||||
});
|
||||
|
||||
test('preview reaches an allow-list of paths, never a pattern', () => {
|
||||
const route = read('src/routes/clinicalAssistant.js');
|
||||
const block = route.slice(route.indexOf('var PREVIEW_PATHS'), route.indexOf('var PREVIEW_USER'));
|
||||
// A pattern would silently include future routes; an exact list means a new
|
||||
// endpoint is private until someone adds it here deliberately.
|
||||
for (const allowed of ['/clinical-assistant/status', '/clinical-assistant/examples',
|
||||
'/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
|
||||
assert.ok(block.includes("'" + allowed + "'"), allowed + ' is previewable');
|
||||
}
|
||||
for (const denied of ['/clinical-assistant/chats', '/clinical-assistant/image',
|
||||
'/clinical-assistant/patient-takehome', '/clinical-assistant/translate']) {
|
||||
assert.ok(!block.includes("'" + denied + "'"), denied + ' must stay private');
|
||||
}
|
||||
assert.match(route, /PREVIEW_PATHS\.has\(req\.path\)/, 'matched exactly, not by prefix');
|
||||
});
|
||||
|
||||
test('preview never downgrades a real session, and a lookup failure closes the door', () => {
|
||||
const route = read('src/routes/clinicalAssistant.js');
|
||||
const mw = route.slice(route.indexOf('router.use(async function(req, res, next)'), route.indexOf('router.use(authMiddleware);'));
|
||||
assert.match(mw, /if \(req\.user\) return next\(\);/, 'an established identity is untouched');
|
||||
assert.match(mw, /hasCredential[\s\S]*?return authMiddleware/,
|
||||
'a caller presenting a token is authenticated normally, not previewed');
|
||||
assert.match(mw, /catch \(_\) \{\s*\n\s*return authMiddleware/,
|
||||
'if the setting cannot be read, authentication is required');
|
||||
});
|
||||
|
||||
test('a preview visitor has no identity, so nothing can be owned or billed', () => {
|
||||
const route = read('src/routes/clinicalAssistant.js');
|
||||
assert.match(route, /PREVIEW_USER = Object\.freeze\(\{ id: null, preview: true/,
|
||||
'no user id at all');
|
||||
// Image generation is owned, stored and paid for, so the tool is withheld
|
||||
// rather than left to fail on a null owner downstream.
|
||||
assert.match(route, /tools: req\.user\.preview \? undefined : imageTool\.tools/);
|
||||
// Each dispatch must be reachable only behind a preview check — either inline
|
||||
// on the same statement, or inside an enclosing `if (!req.user.preview) {`.
|
||||
const lines = route.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (!line.includes('imageTool.dispatch(')) return;
|
||||
const guardedInline = line.includes('!req.user.preview');
|
||||
const guardedByBlock = lines.slice(Math.max(0, i - 3), i)
|
||||
.some(l => l.includes('if (!req.user.preview) {'));
|
||||
assert.ok(guardedInline || guardedByBlock,
|
||||
'unguarded imageTool.dispatch at line ' + (i + 1) + ': ' + line.trim().slice(0, 60));
|
||||
});
|
||||
assert.equal(lines.filter(l => l.includes('imageTool.dispatch(')).length, 2,
|
||||
'both chat paths are covered');
|
||||
assert.match(route, /if \(!req\.user\.preview\) logger\.audit/,
|
||||
'audit rows are not written against a null user');
|
||||
});
|
||||
|
||||
test('preview is entered by declining the login overlay, not by faking a session', () => {
|
||||
const auth = read('public/js/auth.js');
|
||||
const fn = auth.slice(auth.indexOf('function showAuthScreen()'), auth.indexOf('// ── Check for SSO redirect'));
|
||||
// Any path previews, not only /assistant: a visitor landing on the root should
|
||||
// meet the assistant, not a wall. enterPreview moves them onto that tab.
|
||||
assert.doesNotMatch(fn, /pathname === '\/assistant'/, 'the root previews too');
|
||||
assert.match(fn, /data\.success && data\.preview/, 'and only when the server says so');
|
||||
assert.match(fn, /authScreen\.style\.display = 'flex';/, 'anything else raises the login screen');
|
||||
// No token, no user object, no stored credential — the overlay is simply not
|
||||
// shown, so nothing downstream can mistake a visitor for a signed-in user.
|
||||
assert.doesNotMatch(fn, /CURRENT_USER =|SecureStorage\.set/, 'preview never mints an identity');
|
||||
});
|
||||
|
||||
test('reaching for the workspace is where a preview visitor is asked to sign in', () => {
|
||||
const app = read('public/js/app.js');
|
||||
const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;'));
|
||||
assert.match(handler, /assistant-preview'\) && !wantsAssistant/);
|
||||
assert.match(handler, /screen\.style\.display = 'flex'/, 'the login screen is raised');
|
||||
|
||||
// Actions that need an account are hidden rather than offered and refused.
|
||||
const css = read('public/css/styles.css');
|
||||
for (const hidden of ['.assistant-plus', '.assistant-history .card', '.assistant-rail-actions']) {
|
||||
assert.ok(css.includes('body.assistant-preview ' + hidden), hidden + ' is hidden in preview');
|
||||
}
|
||||
assert.match(read('public/components/assistant.html'), /assistant-preview-note/, 'and the state is stated plainly');
|
||||
// The prompt a preview visitor meets is an invitation, not the compliance notice.
|
||||
assert.ok(css.includes('body.assistant-preview .hipaa-notice { display:none; }'), 'no HIPAA notice in preview');
|
||||
});
|
||||
|
||||
test('the browser lets exactly the preview endpoints out without an account, and nothing else', () => {
|
||||
// Every /api fetch without an account is rejected before it is sent. Until the
|
||||
// preview paths were allowed here, the status call never reached the server,
|
||||
// so the server-side preview was unreachable from the page.
|
||||
const af = read('public/js/authFetch.js');
|
||||
const list = af.slice(af.indexOf('var previewPaths'), af.indexOf('function inPreview'));
|
||||
for (const p of ['/api/clinical-assistant/status', '/api/clinical-assistant/examples',
|
||||
'/api/clinical-assistant/chat', '/api/clinical-assistant/chat/stream']) {
|
||||
assert.ok(list.includes("'" + p + "'"), p + ' is allowed');
|
||||
}
|
||||
assert.doesNotMatch(list, /saved-chats|\/config|\/images|\/admin/, 'nothing that needs an account');
|
||||
assert.match(af, /if \(previewPaths\.has\(url\.pathname\)\) return rawFetch/, 'they go out with no ticket');
|
||||
// Anything else a preview visitor reaches for raises sign-in via one hook,
|
||||
// rather than a check on every button.
|
||||
// ...and only for something the visitor did. The page fetches saved chats and
|
||||
// config in the background on load; raising sign-in for those buried the
|
||||
// assistant before a word was typed.
|
||||
assert.match(af, /navigator\.userActivation \? navigator\.userActivation\.isActive : false/);
|
||||
assert.match(af, /if \(inPreview\(\) && gesture\) document\.dispatchEvent\(new CustomEvent\('preview-needs-account'/);
|
||||
assert.match(read('public/js/auth.js'), /addEventListener\('preview-needs-account'/, 'and auth.js raises the screen');
|
||||
});
|
||||
|
|
@ -394,9 +394,6 @@ test('one menu, ending the same way in both views', () => {
|
|||
// Reaching an app tab from the assistant has to leave the assistant.
|
||||
assert.match(app, /if \(tab && window\.location\.pathname === '\/assistant'\)/);
|
||||
|
||||
const css = read('public/css/styles.css');
|
||||
assert.match(css, /body\.assistant-preview \.account-card \{ display:none; \}/,
|
||||
'a preview visitor has no account to show');
|
||||
});
|
||||
|
||||
test('there is no header bar; the brand heads the menu instead', () => {
|
||||
|
|
|
|||
|
|
@ -283,5 +283,5 @@ test('citation quality is measured on the server, where answer and sources both
|
|||
// to fail an answer.
|
||||
assert.match(route, /function citationTracker\(\)/);
|
||||
assert.match(route, /if \(!tracker\) return;/);
|
||||
assert.match(route, /if \(!req\.user\.preview\) tracker\.store\(req\.user\.id, question, result, sources\);/);
|
||||
assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -576,19 +576,3 @@ test('a router mounted on /api must not gate the whole namespace', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('the signed-out preview is reachable, and stays narrow', () => {
|
||||
const route = readSource('src/routes/clinicalAssistant.js');
|
||||
// authMiddleware only ever looks for a token, so calling it unconditionally
|
||||
// after the preview identity was assigned rejected the very requests preview
|
||||
// exists to serve. Only the preview identity may skip it.
|
||||
assert.match(route, /if \(req\.user && req\.user\.preview\) return next\(\);/);
|
||||
assert.match(route, /return authMiddleware\(req, res, next\);/);
|
||||
// Allow-listed by exact path: a route added later is private unless someone
|
||||
// puts it on this list deliberately.
|
||||
const list = route.slice(route.indexOf('var PREVIEW_PATHS'), route.indexOf('var PREVIEW_USER'));
|
||||
assert.match(list, /'\/clinical-assistant\/chat'/);
|
||||
assert.match(list, /'\/clinical-assistant\/chat\/stream'/);
|
||||
assert.doesNotMatch(list, /saved-chats|\/config|\/images/);
|
||||
// A preview visitor has no identity, so nothing can be owned or billed.
|
||||
assert.match(route, /PREVIEW_USER = Object\.freeze\(\{ id: null, preview: true/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue