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_REQUEST_TIMEOUT_MS=90000
# 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_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 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">
<button class="faq-question">How do I download or share a resource?</button>
<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>

View file

@ -677,7 +677,8 @@
return;
}
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');
option.value = String(row.id);
option.textContent = (row.title || 'Untitled') +
@ -785,6 +786,17 @@
// 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' : '');
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(meta);
wrap.appendChild(body);
@ -817,6 +829,17 @@
// 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
// 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.
if (row.kind !== 'article' && themeCatalogue.length > 1) {
var theme = document.createElement('select');
@ -918,6 +941,8 @@
var cloud = event.target.closest && event.target.closest('[data-nextcloud]');
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]');
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]');
@ -960,6 +985,68 @@
.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 ──────────────────────────────────
// 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

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 ─────────────────────────────
// Looking without downloading, on a phone as much as anywhere. A resource is
// 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) {
try {
var row = await db.get(
'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]
);
var row = await readableResource(req.params.id, req.user.id, 'id, kind, markdown, image_ids, deck, theme, updated_at, user_id');
if (!row) return res.status(404).json({ error: 'Not found' });
var key = previewKeyFor(row);
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) {
try {
var row = await db.get(
'SELECT id, kind, theme, updated_at FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
var row = await readableResource(req.params.id, req.user.id, 'id, kind, theme, updated_at');
if (!row) return res.status(404).json({ error: 'Not found' });
var png = await previewPages.page(previewKeyFor(row), parseInt(req.params.page, 10));
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
// weaker modification path — that the owner should be able to see which
// kind they have.
'SELECT id, title, kind, topic, grounded_count, created_at, updated_at, ' +
"(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck, " +
"COALESCE(deck->>'theme', '') AS theme " +
'FROM user_resources WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[req.user.id, MAX_PER_USER]
// Mine, then what others shared with me — with all, or with me by name —
// each row saying which, since the page offers different things for each.
'SELECT r.id, r.title, r.kind, r.topic, r.grounded_count, r.created_at, r.updated_at, ' +
"(r.deck IS NOT NULL AND jsonb_array_length(COALESCE(r.deck->'slides', '[]'::jsonb)) > 0) AS has_deck, " +
"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 });
} catch (err) {
@ -737,11 +809,8 @@ router.get('/my-resources', async function (req, res) {
router.get('/my-resources/:id', async function (req, res) {
try {
var row = await db.get(
'SELECT 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]
);
var row = await readableResource(req.params.id, req.user.id,
'id, title, kind, topic, markdown, grounded_count, created_at, updated_at');
if (!row) return res.status(404).json({ error: 'Not found' });
res.json({ success: true, resource: row });
} catch (err) {
@ -1142,10 +1211,7 @@ router.get('/my-resources/:id/export', async function (req, res) {
var format = String(req.query.format || 'pptx');
if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' });
var row = await db.get(
'SELECT title, kind, markdown, image_ids, deck, theme FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
var row = await readableResource(req.params.id, req.user.id, 'title, kind, markdown, image_ids, deck, theme, user_id');
if (!row) return res.status(404).json({ error: 'Not found' });
// 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 _mcpSessionPromise = null;
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 MCP_CLEANUP_TIMEOUT_MS = 5000;
var _closing = false;
@ -92,16 +103,46 @@ async function indexedTopicSuggestions(limit) {
}
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();
}
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) {
if (_closing) throw new Error('MCP client is closing');
var queued = _mcpCallQueue.then(function() {
return callMcpToolUnlocked(name, args);
});
_mcpCallQueue = queued.catch(function() {});
return queued;
await acquireSlot();
var started = Date.now();
var sessionMs = 0;
try {
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) {

View file

@ -20,11 +20,16 @@ var parameters = {
id: 'Identifier of the record, scoped to the signed-in account.',
workflow: 'Which feature the job belongs to (for example my_resources).',
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 = {
// ── 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/: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' },

View file

@ -38,6 +38,9 @@ function loadClient(overrides = {}) {
if (request === 'axios') return axios;
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;
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']) };

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\/theme-sample\/:id\/preview', 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"));
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.
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\]\)/);

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