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

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:
Daniel 2026-09-11 05:09:02 +02:00
parent cd37a9d728
commit fadf09bf4a
12 changed files with 15 additions and 297 deletions

View file

@ -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>

View file

@ -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">

View file

@ -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. */

View file

@ -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');

View file

@ -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

View file

@ -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) ──

View file

@ -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')

View file

@ -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

View file

@ -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');
});

View file

@ -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', () => {

View file

@ -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\);/);
});

View file

@ -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/);
});