feat: share a resource with people on this site — by email, or with everyone
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:
parent
6992ecdf80
commit
eb9fdfef35
12 changed files with 356 additions and 32 deletions
|
|
@ -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=
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
27
migrations/1781300000000_resource-shares.js
Normal file
27
migrations/1781300000000_resource-shares.js
Normal 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;
|
||||
`);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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']) };
|
||||
|
|
|
|||
34
test/mcp-client-concurrency.test.js
Normal file
34
test/mcp-client-concurrency.test.js
Normal 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='/);
|
||||
});
|
||||
|
|
@ -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\]\)/);
|
||||
|
|
|
|||
41
test/resource-sharing.test.js
Normal file
41
test/resource-sharing.test.js
Normal 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\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue