From eb9fdfef359c89e34c21857d63417a706219600d Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 15:13:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20share=20a=20resource=20with=20people=20?= =?UTF-8?q?on=20this=20site=20=E2=80=94=20by=20email,=20or=20with=20everyo?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- .env.example | 1 + docs/my-resources.md | 18 ++++ migrations/1781300000000_resource-shares.js | 27 +++++ public/components/faq.html | 3 +- public/js/myResources.js | 89 +++++++++++++++- src/routes/myResources.js | 110 ++++++++++++++++---- src/utils/clinicalMcpClient.js | 51 ++++++++- src/utils/openapiRoutes.js | 7 +- test/clinical-mcp-session-lifecycle.test.js | 3 + test/mcp-client-concurrency.test.js | 34 ++++++ test/resource-previews.test.js | 4 +- test/resource-sharing.test.js | 41 ++++++++ 12 files changed, 356 insertions(+), 32 deletions(-) create mode 100644 migrations/1781300000000_resource-shares.js create mode 100644 test/mcp-client-concurrency.test.js create mode 100644 test/resource-sharing.test.js diff --git a/.env.example b/.env.example index 87de1ff2..d1356ad7 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/docs/my-resources.md b/docs/my-resources.md index d018dd1b..6d05cff6 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -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`. diff --git a/migrations/1781300000000_resource-shares.js b/migrations/1781300000000_resource-shares.js new file mode 100644 index 00000000..4009a202 --- /dev/null +++ b/migrations/1781300000000_resource-shares.js @@ -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; +`); diff --git a/public/components/faq.html b/public/components/faq.html index 721cefe9..0fdc1749 100644 --- a/public/components/faq.html +++ b/public/components/faq.html @@ -278,7 +278,8 @@
-

Download as PowerPoint, Word or PDF, 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.

+

Preview shows every page in place, on a phone too. Download as PowerPoint, Word or PDF, or send the rendered file straight to your Nextcloud; articles download as Word or PDF.

+

Share 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 …".

diff --git a/public/js/myResources.js b/public/js/myResources.js index 60a954c0..9d893f89 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -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 = ' 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 = '' + + '
' + + '
' + + '
'; + 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 cannot diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 58e6d964..f5d5d11f 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -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: diff --git a/src/utils/clinicalMcpClient.js b/src/utils/clinicalMcpClient.js index c5468030..565610af 100644 --- a/src/utils/clinicalMcpClient.js +++ b/src/utils/clinicalMcpClient.js @@ -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) { diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js index 99533c03..bc150933 100644 --- a/src/utils/openapiRoutes.js +++ b/src/utils/openapiRoutes.js @@ -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' }, diff --git a/test/clinical-mcp-session-lifecycle.test.js b/test/clinical-mcp-session-lifecycle.test.js index d0bbb5d2..855bb362 100644 --- a/test/clinical-mcp-session-lifecycle.test.js +++ b/test/clinical-mcp-session-lifecycle.test.js @@ -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']) }; diff --git a/test/mcp-client-concurrency.test.js b/test/mcp-client-concurrency.test.js new file mode 100644 index 00000000..47dff1e3 --- /dev/null +++ b/test/mcp-client-concurrency.test.js @@ -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='/); +}); diff --git a/test/resource-previews.test.js b/test/resource-previews.test.js index e49b4da7..27196ed4 100644 --- a/test/resource-previews.test.js +++ b/test/resource-previews.test.js @@ -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\]\)/); diff --git a/test/resource-sharing.test.js b/test/resource-sharing.test.js new file mode 100644 index 00000000..1340ef47 --- /dev/null +++ b/test/resource-sharing.test.js @@ -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\)/); +});