feat: share a resource with people on this site — by email, or with everyone
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 7s

A resource was private with no way out but the author's own Nextcloud.
Share opens reading — open, preview, download — to one person at a time by
exact email (no account is ever listed) or to everyone signed in with one
switch; what others share appears in your library marked "Shared by …".
Writing never travels: modify, re-skin, delete and the share list stay the
author's, every write still filtered on user_id, and the read routes go
through one reader rule. Rows follow the resource and the person.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 15:13:28 +02:00
parent 6992ecdf80
commit eb9fdfef35
12 changed files with 356 additions and 32 deletions

View file

@ -253,6 +253,7 @@ DB_PASSWORD=pedscribe_secret_change_me
# CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS=30000 # CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS=30000
# CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS=90000 # CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS=90000
# CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS=600000 # CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS=600000
# CLINICAL_ASSISTANT_MCP_CONCURRENCY=3 # library searches in flight at once; they used to run one at a time
# CLINICAL_ASSISTANT_MCP_WARMUP= # open a session at boot # CLINICAL_ASSISTANT_MCP_WARMUP= # open a session at boot
# CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS= # CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS=

View file

@ -421,3 +421,21 @@ generating — it had none, so "add what the 2024 trial showed" was answered fro
the model's memory rather than by looking anything up. the model's memory rather than by looking anything up.
The previous version is replaced, not versioned. The previous version is replaced, not versioned.
## Sharing
A resource is its author's. `POST /api/my-resources/:id/shares` (by email,
exact — accounts are never listed) and `PUT …/shares/all` extend *reading*
open, preview, download — to named people or to everyone signed in; the rows
live in `user_resource_shares` and `user_resources.shared_with_all`. Every
write route (modify, theme, delete, the share list itself) still filters on
`user_id`. The list route returns a person's own resources first, then what is
shared with them, each row saying `owned` and `shared_by_name`.
## Preview
`GET /api/my-resources/:id/preview` renders the resource the way its download
is built and turns it into one PNG per page (`src/utils/previewPages.js`:
Gotenberg to PDF, `pdftoppm` to pages), keyed on `updated_at` and theme and
kept under the OS temp directory; `…/preview/:page` serves a page. Theme
samples have the same pair under `theme-sample/:id/preview`.

View file

@ -0,0 +1,27 @@
// Sharing a resource with other people on this site.
//
// A resource was private to its author with no way out but the author's own
// Nextcloud. A share is a row per (resource, person): the person can open,
// preview and download it — not modify, re-skin or delete it — and the author
// can withdraw it. shared_with_all opens a resource to every signed-in account
// without naming them.
//
// Rows go with the resource and with the person: a deleted account leaves no
// dangling grant, and a deleted resource leaves no orphan share.
exports.up = pgm => pgm.sql(`
ALTER TABLE user_resources ADD COLUMN IF NOT EXISTS shared_with_all BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS user_resource_shares (
resource_id INTEGER NOT NULL REFERENCES user_resources(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_resource_shares_user ON user_resource_shares (user_id);
`);
exports.down = pgm => pgm.sql(`
DROP TABLE IF EXISTS user_resource_shares;
ALTER TABLE user_resources DROP COLUMN IF EXISTS shared_with_all;
`);

View file

@ -278,7 +278,8 @@
<div class="faq-item"> <div class="faq-item">
<button class="faq-question">How do I download or share a resource?</button> <button class="faq-question">How do I download or share a resource?</button>
<div class="faq-answer"> <div class="faq-answer">
<p>Download as <strong>PowerPoint</strong>, <strong>Word</strong> or <strong>PDF</strong>, or send the rendered file straight to your Nextcloud. Articles download as Word or PDF. Sharing a resource with another person is not available yet.</p> <p><strong>Preview</strong> shows every page in place, on a phone too. Download as <strong>PowerPoint</strong>, <strong>Word</strong> or <strong>PDF</strong>, or send the rendered file straight to your Nextcloud; articles download as Word or PDF.</p>
<p><strong>Share</strong> lets other people on this site open, preview and download it — one person at a time by email, or everyone signed in with one switch — without being able to change it. What others share with you appears in your library marked "Shared by …".</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -677,7 +677,8 @@
return; return;
} }
select.disabled = false; select.disabled = false;
library.forEach(function (row) { // Only what is mine can be modified; a share is read-only.
library.filter(function (row) { return row.owned !== false; }).forEach(function (row) {
var option = document.createElement('option'); var option = document.createElement('option');
option.value = String(row.id); option.value = String(row.id);
option.textContent = (row.title || 'Untitled') + option.textContent = (row.title || 'Untitled') +
@ -785,6 +786,17 @@
// Only worth saying when it is the weaker kind. A deck is the normal case. // Only worth saying when it is the weaker kind. A deck is the normal case.
(row.kind !== 'article' && row.has_deck === false ? ' · plain text, no slide layout' : ''); (row.kind !== 'article' && row.has_deck === false ? ' · plain text, no slide layout' : '');
if (row.owned === false && row.shared_by_name) {
var from = document.createElement('div');
from.style.cssText = 'font-size:11px;color:var(--blue);';
from.textContent = 'Shared by ' + row.shared_by_name;
body.appendChild(from);
} else if (row.owned !== false && row.shared_with_all) {
var all = document.createElement('div');
all.style.cssText = 'font-size:11px;color:var(--g500);';
all.textContent = 'Shared with everyone';
body.appendChild(all);
}
body.appendChild(title); body.appendChild(title);
body.appendChild(meta); body.appendChild(meta);
wrap.appendChild(body); wrap.appendChild(body);
@ -817,6 +829,17 @@
// Re-skinning is a column write, not a regeneration: the next download // Re-skinning is a column write, not a regeneration: the next download
// renders from the same deck in different colours. Only for a row that has // renders from the same deck in different colours. Only for a row that has
// a deck — flat markdown has no palette to change. // a deck — flat markdown has no palette to change.
// Reading is what a share gives; changing is the author's.
if (row.owned === false) { return wrap; }
var share = document.createElement('button');
share.className = 'btn-sm btn-ghost';
share.type = 'button';
share.dataset.share = String(row.id);
share.innerHTML = '<i class="fas fa-user-plus"></i> Share';
share.title = 'Share with people on this site';
wrap.appendChild(share);
// Every presentation takes a theme now — markdown slides too. // Every presentation takes a theme now — markdown slides too.
if (row.kind !== 'article' && themeCatalogue.length > 1) { if (row.kind !== 'article' && themeCatalogue.length > 1) {
var theme = document.createElement('select'); var theme = document.createElement('select');
@ -918,6 +941,8 @@
var cloud = event.target.closest && event.target.closest('[data-nextcloud]'); var cloud = event.target.closest && event.target.closest('[data-nextcloud]');
if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud); if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud);
var shareBtn = event.target.closest && event.target.closest('[data-share]');
if (shareBtn) { openSharePanel(shareBtn.dataset.share, shareBtn.closest('.saved-enc-item')); return; }
var preview = event.target.closest && event.target.closest('[data-preview]'); var preview = event.target.closest && event.target.closest('[data-preview]');
if (preview) { openPreview('/api/my-resources/' + encodeURIComponent(preview.dataset.preview) + '/preview', preview.dataset.previewTitle || 'Preview'); return; } if (preview) { openPreview('/api/my-resources/' + encodeURIComponent(preview.dataset.preview) + '/preview', preview.dataset.previewTitle || 'Preview'); return; }
var download = event.target.closest && event.target.closest('[data-download]'); var download = event.target.closest && event.target.closest('[data-download]');
@ -960,6 +985,68 @@
.finally(function () { btn.disabled = false; btn.textContent = original; }); .finally(function () { btn.disabled = false; btn.textContent = original; });
} }
// ── Sharing ──────────────────────────────────────────────
// Under the row: a switch for everyone, an email to add one person, and the
// list of people it is shared with, each with a way to withdraw.
function openSharePanel(id, rowEl) {
if (!rowEl) return;
var existing = rowEl.querySelector('.mr-share');
if (existing) { existing.remove(); return; }
var panel = document.createElement('div');
panel.className = 'mr-share';
panel.style.cssText = 'flex-basis:100%;margin-top:8px;padding:10px 12px;border:1px solid var(--g200);border-radius:8px;background:var(--g50);display:grid;gap:8px;font-size:13px;';
panel.innerHTML = '<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" class="mr-share-all"> Anyone signed in to this site can open it</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;"><input type="email" class="mr-share-email admin-control" placeholder="Share with someone by email" style="flex:1;min-width:200px;">' +
'<button type="button" class="btn-sm btn-primary mr-share-add">Add</button></div>' +
'<div class="mr-share-people" style="display:grid;gap:4px;"></div>';
rowEl.appendChild(panel);
var base = '/api/my-resources/' + encodeURIComponent(id) + '/shares';
function paint(state) {
panel.querySelector('.mr-share-all').checked = !!state.sharedWithAll;
var list = panel.querySelector('.mr-share-people');
list.innerHTML = '';
if (!state.people.length) {
var none = document.createElement('div'); none.style.cssText = 'font-size:12px;color:var(--g500);';
none.textContent = state.sharedWithAll ? 'Shared with everyone.' : 'Not shared with anyone yet.';
list.appendChild(none); return;
}
state.people.forEach(function (p) {
var line = document.createElement('div');
line.style.cssText = 'display:flex;align-items:center;gap:8px;';
var who = document.createElement('span'); who.style.flex = '1';
who.textContent = (p.name ? p.name + ' · ' : '') + p.email;
var out = document.createElement('button'); out.type = 'button'; out.className = 'btn-sm btn-ghost';
out.textContent = 'Remove'; out.title = 'Withdraw the share';
out.addEventListener('click', function () {
fetch(base + '/' + encodeURIComponent(p.id), { method: 'DELETE', headers: getAuthHeaders() })
.then(function (r) { return r.json(); }).then(load).catch(function (e) { showToast(e.message, 'error'); });
});
line.appendChild(who); line.appendChild(out); list.appendChild(line);
});
}
function load() {
return fetch(base, { headers: getAuthHeaders() }).then(function (r) { return r.json(); })
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not load'); paint(d); loadLibrary(); })
.catch(function (e) { showToast(e.message, 'error'); });
}
panel.querySelector('.mr-share-all').addEventListener('change', function (e) {
fetch(base + '/all', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: e.target.checked }) })
.then(function (r) { return r.json(); })
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not change sharing'); showToast(d.sharedWithAll ? 'Shared with everyone on the site' : 'No longer shared with everyone', 'success'); return load(); })
.catch(function (err) { showToast(err.message, 'error'); load(); });
});
panel.querySelector('.mr-share-add').addEventListener('click', function () {
var input = panel.querySelector('.mr-share-email');
var email = (input.value || '').trim();
if (!email) { input.focus(); return; }
fetch(base, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ email: email }) })
.then(function (r) { return r.json(); })
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not share'); input.value = ''; showToast('Shared with ' + (d.person.name || d.person.email), 'success'); return load(); })
.catch(function (err) { showToast(err.message, 'error'); });
});
load();
}
// ── The preview gallery ────────────────────────────────── // ── The preview gallery ──────────────────────────────────
// One overlay: the pages of a document, top to bottom, at the width of the // One overlay: the pages of a document, top to bottom, at the width of the
// screen. Each page is fetched with the auth header (an <img src> cannot // screen. Each page is fetched with the auth header (an <img src> cannot

View file

@ -587,6 +587,77 @@ router.post('/my-resources/:id/to-nextcloud', async function (req, res) {
} }
}); });
// ── Sharing ─────────────────────────────────────────────────
// A resource is the author's. Reading it — open, preview, download — is
// extended to people the author named, or to everyone signed in when the
// author said so. Writing it never is: modify, re-skin, delete and the share
// list itself stay with the author, and every route that writes still filters
// on user_id as before.
async function readableResource(id, userId, columns) {
return db.get(
'SELECT ' + columns.split(',').map(function (c) { return 'r.' + c.trim(); }).join(', ') +
' FROM user_resources r LEFT JOIN user_resource_shares s ON s.resource_id = r.id AND s.user_id = ? ' +
'WHERE r.id = ? AND (r.user_id = ? OR r.shared_with_all OR s.user_id IS NOT NULL)',
[userId, parseInt(id, 10), userId]
);
}
async function ownedResource(id, userId) {
return db.get('SELECT id, title, shared_with_all FROM user_resources WHERE id = ? AND user_id = ?', [parseInt(id, 10), userId]);
}
router.get('/my-resources/:id/shares', async function (req, res) {
try {
var row = await ownedResource(req.params.id, req.user.id);
if (!row) return res.status(404).json({ error: 'Not found' });
var people = await db.all(
'SELECT u.id, u.name, u.email FROM user_resource_shares s JOIN users u ON u.id = s.user_id ' +
'WHERE s.resource_id = ? ORDER BY s.created_at', [row.id]);
res.json({ success: true, sharedWithAll: !!row.shared_with_all, people: people });
} catch (err) { res.status(500).json({ error: 'Could not load the share list' }); }
});
// Everyone signed in, or not. A switch rather than a list, since "the whole
// department" has no useful list.
router.put('/my-resources/:id/shares/all', async function (req, res) {
try {
var row = await ownedResource(req.params.id, req.user.id);
if (!row) return res.status(404).json({ error: 'Not found' });
var on = req.body.value === true || String(req.body.value) === 'true';
await db.run('UPDATE user_resources SET shared_with_all = ? WHERE id = ? AND user_id = ?', [on, row.id, req.user.id]);
logger.audit(req.user.id, on ? 'resource_share_all' : 'resource_unshare_all', 'Resource ' + row.id + (on ? ' shared with everyone' : ' no longer shared with everyone'), req, { category: 'clinical' });
res.json({ success: true, sharedWithAll: on });
} catch (err) { res.status(500).json({ error: 'Could not change sharing' }); }
});
// By email, exactly. Nothing here lists accounts: an address that is not one
// is refused, and the author is told so, without turning this into a
// directory.
router.post('/my-resources/:id/shares', async function (req, res) {
try {
var row = await ownedResource(req.params.id, req.user.id);
if (!row) return res.status(404).json({ error: 'Not found' });
var email = String(req.body.email || '').trim().toLowerCase();
if (!email || email.length > 320) return res.status(400).json({ error: 'Enter the person\'s email' });
var person = await db.get('SELECT id, name, email FROM users WHERE email = ? AND disabled IS NOT TRUE', [email]);
if (!person) return res.status(404).json({ error: 'No account with that email on this site' });
if (person.id === req.user.id) return res.status(400).json({ error: 'That is you' });
await db.run('INSERT INTO user_resource_shares (resource_id, user_id, shared_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING',
[row.id, person.id, req.user.id]);
logger.audit(req.user.id, 'resource_share', 'Resource ' + row.id + ' shared with user ' + person.id, req, { category: 'clinical' });
res.json({ success: true, person: person });
} catch (err) { res.status(500).json({ error: 'Could not share it' }); }
});
router.delete('/my-resources/:id/shares/:userId', async function (req, res) {
try {
var row = await ownedResource(req.params.id, req.user.id);
if (!row) return res.status(404).json({ error: 'Not found' });
await db.run('DELETE FROM user_resource_shares WHERE resource_id = ? AND user_id = ?', [row.id, parseInt(req.params.userId, 10)]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: 'Could not withdraw the share' }); }
});
// ── Previews: pages as pictures ───────────────────────────── // ── Previews: pages as pictures ─────────────────────────────
// Looking without downloading, on a phone as much as anywhere. A resource is // Looking without downloading, on a phone as much as anywhere. A resource is
// rendered the way its download would be (PowerPoint for a presentation, // rendered the way its download would be (PowerPoint for a presentation,
@ -612,10 +683,7 @@ async function resourceOfficeBytes(row, user) {
router.get('/my-resources/:id/preview', async function (req, res) { router.get('/my-resources/:id/preview', async function (req, res) {
try { try {
var row = await db.get( var row = await readableResource(req.params.id, req.user.id, 'id, kind, markdown, image_ids, deck, theme, updated_at, user_id');
'SELECT id, kind, markdown, image_ids, deck, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' }); if (!row) return res.status(404).json({ error: 'Not found' });
var key = previewKeyFor(row); var key = previewKeyFor(row);
var pages = await previewPages.ensure(key, function () { return resourceOfficeBytes(row, req.user); }); var pages = await previewPages.ensure(key, function () { return resourceOfficeBytes(row, req.user); });
@ -628,10 +696,7 @@ router.get('/my-resources/:id/preview', async function (req, res) {
router.get('/my-resources/:id/preview/:page', async function (req, res) { router.get('/my-resources/:id/preview/:page', async function (req, res) {
try { try {
var row = await db.get( var row = await readableResource(req.params.id, req.user.id, 'id, kind, theme, updated_at');
'SELECT id, kind, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' }); if (!row) return res.status(404).json({ error: 'Not found' });
var png = await previewPages.page(previewKeyFor(row), parseInt(req.params.page, 10)); var png = await previewPages.page(previewKeyFor(row), parseInt(req.params.page, 10));
if (!png) return res.status(404).json({ error: 'No such page' }); if (!png) return res.status(404).json({ error: 'No such page' });
@ -722,11 +787,18 @@ router.get('/my-resources', async function (req, res) {
// presentation without one behaves differently enough — flat layout, a // presentation without one behaves differently enough — flat layout, a
// weaker modification path — that the owner should be able to see which // weaker modification path — that the owner should be able to see which
// kind they have. // kind they have.
'SELECT id, title, kind, topic, grounded_count, created_at, updated_at, ' + // Mine, then what others shared with me — with all, or with me by name —
"(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck, " + // each row saying which, since the page offers different things for each.
"COALESCE(deck->>'theme', '') AS theme " + 'SELECT r.id, r.title, r.kind, r.topic, r.grounded_count, r.created_at, r.updated_at, ' +
'FROM user_resources WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', "(r.deck IS NOT NULL AND jsonb_array_length(COALESCE(r.deck->'slides', '[]'::jsonb)) > 0) AS has_deck, " +
[req.user.id, MAX_PER_USER] "COALESCE(r.theme, r.deck->>'theme', '') AS theme, " +
'(r.user_id = ?) AS owned, r.shared_with_all, ' +
'CASE WHEN r.user_id = ? THEN NULL ELSE u.name END AS shared_by_name ' +
'FROM user_resources r JOIN users u ON u.id = r.user_id ' +
'LEFT JOIN user_resource_shares s ON s.resource_id = r.id AND s.user_id = ? ' +
'WHERE r.user_id = ? OR r.shared_with_all OR s.user_id IS NOT NULL ' +
'ORDER BY (r.user_id = ?) DESC, r.created_at DESC LIMIT ?',
[req.user.id, req.user.id, req.user.id, req.user.id, req.user.id, MAX_PER_USER * 2]
); );
res.json({ success: true, resources: rows }); res.json({ success: true, resources: rows });
} catch (err) { } catch (err) {
@ -737,11 +809,8 @@ router.get('/my-resources', async function (req, res) {
router.get('/my-resources/:id', async function (req, res) { router.get('/my-resources/:id', async function (req, res) {
try { try {
var row = await db.get( var row = await readableResource(req.params.id, req.user.id,
'SELECT id, title, kind, topic, markdown, grounded_count, created_at, updated_at ' + 'id, title, kind, topic, markdown, grounded_count, created_at, updated_at');
'FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' }); if (!row) return res.status(404).json({ error: 'Not found' });
res.json({ success: true, resource: row }); res.json({ success: true, resource: row });
} catch (err) { } catch (err) {
@ -1142,10 +1211,7 @@ router.get('/my-resources/:id/export', async function (req, res) {
var format = String(req.query.format || 'pptx'); var format = String(req.query.format || 'pptx');
if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' }); if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' });
var row = await db.get( var row = await readableResource(req.params.id, req.user.id, 'title, kind, markdown, image_ids, deck, theme, user_id');
'SELECT title, kind, markdown, image_ids, deck, theme FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' }); if (!row) return res.status(404).json({ error: 'Not found' });
// The UI does not offer it, but the route is the boundary that matters: // The UI does not offer it, but the route is the boundary that matters:

View file

@ -16,6 +16,17 @@ var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_
var _mcpSession = null; var _mcpSession = null;
var _mcpSessionPromise = null; var _mcpSessionPromise = null;
var _mcpCallQueue = Promise.resolve(); var _mcpCallQueue = Promise.resolve();
// How many tool calls may be in flight at once. They used to run one at a
// time behind a promise chain, so two people asking at the same moment
// waited for each other; the server handles concurrent requests on one
// session, so a small bound is enough to stop the queueing without letting a
// burst pile onto it.
var MCP_CONCURRENCY = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3);
var _inFlight = 0;
var _waiting = [];
// The session is renewed before it expires, in the background, so no one
// pays for a cold initialize at the start of their question.
var _warmTimer = null;
var _mcpRequestId = 1; var _mcpRequestId = 1;
var MCP_CLEANUP_TIMEOUT_MS = 5000; var MCP_CLEANUP_TIMEOUT_MS = 5000;
var _closing = false; var _closing = false;
@ -92,16 +103,46 @@ async function indexedTopicSuggestions(limit) {
} }
function warmMcpSession() { function warmMcpSession() {
if (!_warmTimer && MCP_SESSION_TTL_MS > 0) {
_warmTimer = setInterval(function() {
if (_closing) return;
var soon = _mcpSession && _mcpSession.expiresAt - Date.now() < MCP_SESSION_TTL_MS / 3;
if (!_mcpSession || soon) {
if (soon) { var old = _mcpSession; _mcpSession = null; endMcpSession(old); }
getMcpSession().catch(function() {});
}
}, Math.max(30000, Math.floor(MCP_SESSION_TTL_MS / 3)));
if (_warmTimer.unref) _warmTimer.unref();
}
return getMcpSession(); return getMcpSession();
} }
function acquireSlot() {
if (_inFlight < MCP_CONCURRENCY) { _inFlight++; return Promise.resolve(); }
return new Promise(function(resolve) { _waiting.push(resolve); });
}
function releaseSlot() {
var next = _waiting.shift();
if (next) next(); else _inFlight--;
}
async function callMcpTool(name, args) { async function callMcpTool(name, args) {
if (_closing) throw new Error('MCP client is closing'); if (_closing) throw new Error('MCP client is closing');
var queued = _mcpCallQueue.then(function() { await acquireSlot();
return callMcpToolUnlocked(name, args); var started = Date.now();
}); var sessionMs = 0;
_mcpCallQueue = queued.catch(function() {}); try {
return queued; var t = Date.now();
await getMcpSession();
sessionMs = Date.now() - t;
return await callMcpToolUnlocked(name, args);
} finally {
releaseSlot();
// Where a slow search spent its time: opening a session or running the
// call. The one question worth answering when "search=6657" shows up.
console.info('[clinical-assistant] mcp ' + name + ': session=' + sessionMs + 'ms total=' + (Date.now() - started) + 'ms in_flight=' + _inFlight);
}
} }
async function callMcpToolUnlocked(name, args) { async function callMcpToolUnlocked(name, args) {

View file

@ -20,11 +20,16 @@ var parameters = {
id: 'Identifier of the record, scoped to the signed-in account.', id: 'Identifier of the record, scoped to the signed-in account.',
workflow: 'Which feature the job belongs to (for example my_resources).', workflow: 'Which feature the job belongs to (for example my_resources).',
key: 'Setting key, for example clinical_assistant.chat_model.', key: 'Setting key, for example clinical_assistant.chat_model.',
slug: 'URL-safe name of the document.' slug: 'URL-safe name of the document.',
userId: 'Id of the account the share was for.'
}; };
var operations = { var operations = {
// ── Speech ────────────────────────────────────────────────────────── // ── Speech ──────────────────────────────────────────────────────────
'GET /api/my-resources/:id/shares': { summary: 'Who a resource is shared with (owner only)' },
'PUT /api/my-resources/:id/shares/all': { summary: 'Share a resource with everyone signed in, or stop', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { value: { type: 'boolean' } } } } } } },
'POST /api/my-resources/:id/shares': { summary: 'Share a resource with one person, by email', description: 'Refused with 404 when no account has that email; accounts are never listed.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string' } } } } } } },
'DELETE /api/my-resources/:id/shares/:userId': { summary: 'Withdraw a share' },
'GET /api/my-resources/:id/preview': { summary: 'Render a resource as pages for viewing in place', description: 'Returns the page count and a key; pages are served as PNG by the sibling route. Rendered once per version (updated_at and theme).' }, 'GET /api/my-resources/:id/preview': { summary: 'Render a resource as pages for viewing in place', description: 'Returns the page count and a key; pages are served as PNG by the sibling route. Rendered once per version (updated_at and theme).' },
'GET /api/my-resources/:id/preview/:page': { summary: 'One page of a resource preview, as PNG' }, 'GET /api/my-resources/:id/preview/:page': { summary: 'One page of a resource preview, as PNG' },
'GET /api/my-resources/theme-sample/:id/preview': { summary: 'Render a theme\'s sample deck as pages' }, 'GET /api/my-resources/theme-sample/:id/preview': { summary: 'Render a theme\'s sample deck as pages' },

View file

@ -38,6 +38,9 @@ function loadClient(overrides = {}) {
if (request === 'axios') return axios; if (request === 'axios') return axios;
return original.apply(this, arguments); return original.apply(this, arguments);
}; };
// These tests reason about one call after another; the pool is bounded to
// one here so the order they assert is the order that happens.
process.env.CLINICAL_ASSISTANT_MCP_CONCURRENCY = '1';
let client; let client;
try { client = require(target); } finally { Module._load = original; } try { client = require(target); } finally { Module._load = original; }
return { client, calls, deletes: () => calls.filter(c => c.method === 'DELETE').map(c => c.config.headers['mcp-session-id']) }; return { client, calls, deletes: () => calls.filter(c => c.method === 'DELETE').map(c => c.config.headers['mcp-session-id']) };

View file

@ -0,0 +1,34 @@
// Library searches run a few at a time rather than one behind another, and
// the session is renewed before it expires.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8');
test('a bounded number of calls run at once; the rest wait their turn', async () => {
// The slot logic on its own, with the environment's default bound.
const slice = src.slice(src.indexOf('function acquireSlot'), src.indexOf('async function callMcpTool'));
const fn = new Function('MCP_CONCURRENCY', 'var _inFlight = 0, _waiting = [];' + slice + '; return { acquireSlot, releaseSlot, count: () => _inFlight, waiting: () => _waiting.length };')(3);
await fn.acquireSlot(); await fn.acquireSlot(); await fn.acquireSlot();
assert.equal(fn.count(), 3);
let fourthStarted = false;
const fourth = fn.acquireSlot().then(() => { fourthStarted = true; });
await new Promise(r => setTimeout(r, 5));
assert.equal(fourthStarted, false, 'the fourth waits');
assert.equal(fn.waiting(), 1);
fn.releaseSlot();
await fourth;
assert.equal(fourthStarted, true, 'and runs when a slot frees');
assert.equal(fn.count(), 3, 'the slot passed hands rather than being freed');
fn.releaseSlot(); fn.releaseSlot(); fn.releaseSlot();
assert.equal(fn.count(), 0);
});
test('the serial promise chain is gone, the session is kept warm, and the split is logged', () => {
assert.doesNotMatch(src, /_mcpCallQueue = queued\.catch/, 'no more one-at-a-time chain');
assert.match(src, /var MCP_CONCURRENCY = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3\)/);
assert.match(src, /setInterval\(function\(\) \{[\s\S]*?getMcpSession\(\)\.catch/, 'renewed on a timer');
assert.match(src, /_warmTimer\.unref/, 'the timer never keeps the process alive');
assert.match(src, /mcp ' \+ name \+ ': session=' \+ sessionMs \+ 'ms total='/);
});

View file

@ -24,9 +24,9 @@ test('previews are served for a resource and for a theme sample, and only to the
assert.match(route, /router\.get\('\/my-resources\/:id\/preview\/:page', async/); assert.match(route, /router\.get\('\/my-resources\/:id\/preview\/:page', async/);
assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview', async/); assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview', async/);
assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview\/:page', async/); assert.match(route, /router\.get\('\/my-resources\/theme-sample\/:id\/preview\/:page', async/);
// The resource routes read with the owner in the WHERE, like every other route here. // The resource routes read through the reader rule: the owner, or someone it is shared with.
const preview = route.slice(route.indexOf("router.get('/my-resources/:id/preview'"), route.indexOf("// The sample deck of a theme")); const preview = route.slice(route.indexOf("router.get('/my-resources/:id/preview'"), route.indexOf("// The sample deck of a theme"));
assert.equal((preview.match(/AND user_id = \?/g) || []).length, 2); assert.equal((preview.match(/await readableResource\(req\.params\.id, req\.user\.id/g) || []).length, 2);
// Rendered the way the download is: the same exporter, the same theme. // Rendered the way the download is: the same exporter, the same theme.
assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\{ images: figures, deck: row\.deck, theme: row\.theme, figureIds: figureIdList\(row\.image_ids\) \}\)/); assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\{ images: figures, deck: row\.deck, theme: row\.theme, figureIds: figureIdList\(row\.image_ids\) \}\)/);
assert.match(route, /previewPages\.cacheKey\(\['resource', row\.id, row\.updated_at, row\.theme \|\| '', row\.kind\]\)/); assert.match(route, /previewPages\.cacheKey\(\['resource', row\.id, row\.updated_at, row\.theme \|\| '', row\.kind\]\)/);

View file

@ -0,0 +1,41 @@
// Sharing a resource: reading is extended to named people or to everyone
// signed in; writing never is.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
test('every read route goes through the reader rule; every write route still filters on the owner', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /WHERE r\.id = \? AND \(r\.user_id = \? OR r\.shared_with_all OR s\.user_id IS NOT NULL\)/);
for (const marker of ["router.get('/my-resources/:id'", "router.get('/my-resources/:id/export'", "router.get('/my-resources/:id/preview'", "router.get('/my-resources/:id/preview/:page'"]) {
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
assert.match(body, /await readableResource\(req\.params\.id, req\.user\.id/, marker + ' reads through the rule');
}
for (const marker of ["router.put('/my-resources/:id/theme'", "router.post('/my-resources/:id/refine'", "router.delete('/my-resources/:id'", "router.put('/my-resources/:id'"]) {
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
assert.match(body, /AND user_id = \?/, marker + ' stays the owner\'s');
assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers');
}
// The share list itself is the owner's, and adding by email never lists accounts.
for (const marker of ["router.get('/my-resources/:id/shares'", "router.put('/my-resources/:id/shares/all'", "router.post('/my-resources/:id/shares'", "router.delete('/my-resources/:id/shares/:userId'"]) {
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
assert.match(body, /await ownedResource\(req\.params\.id, req\.user\.id\)/, marker + ' is owner-only');
}
assert.match(route, /SELECT id, name, email FROM users WHERE email = \? AND disabled IS NOT TRUE/);
assert.match(route, /No account with that email on this site/);
assert.match(route, /ON CONFLICT DO NOTHING/, 'sharing twice is not an error');
});
test('the list carries whose each row is, and the page offers changes only on your own', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /\(r\.user_id = \?\) AS owned, r\.shared_with_all/);
assert.match(route, /CASE WHEN r\.user_id = \? THEN NULL ELSE u\.name END AS shared_by_name/);
const js = read('public/js/myResources.js');
assert.match(js, /if \(row\.owned === false\) \{ return wrap; \}/, 'a shared row has Preview and downloads, nothing else');
assert.match(js, /library\.filter\(function \(row\) \{ return row\.owned !== false; \}\)/, 'Modify offers only your own');
assert.match(js, /'Shared by ' \+ row\.shared_by_name/);
assert.match(js, /mr-share-all/); assert.match(js, /mr-share-email/); assert.match(js, /Withdraw the share/);
assert.match(read('migrations/1781300000000_resource-shares.js'), /PRIMARY KEY \(resource_id, user_id\)/);
});