feat: connect Nextcloud by signing in to Nextcloud
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m7s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

Asking someone to find Settings → Security → Create new app password is a poor
first run, and it is the step people give up on. Nextcloud has its own answer:
Login Flow v2. The person enters their server address, signs in on Nextcloud the
way they normally do — SSO, 2FA, a password manager — and Nextcloud generates an
app password for this app. We never see their real password.

Pasting an app password still works, behind "Use an app password instead". It is
the fallback, not the front door.

The security of this is all in what is trusted. The remote server chooses both
the login URL and the poll endpoint, so both are SSRF-checked and both must be
on the host the person actually typed — an endpoint pointing elsewhere would
make this a request-forgery gadget aimed at whatever it named. The server
Nextcloud reports at the end is re-checked before it is stored. The poll token
is a credential, so polling happens server-side and the browser holds only an
opaque handle bound to its own account.

Flows live in memory with a 20 minute life, matching Nextcloud's own expiry: a
login lasts minutes, and a restart mid-flow is a retry rather than a loss.
Starting a second flow replaces the first, which is what clicking again means.

The tab is opened from the click itself, before the request — opening it after
an await is what a popup blocker stops.

Removed with Learning Hub: the WebDAV browse path. Its field, its route and its
column are gone, since nothing browses Nextcloud any more. nextcloud_folder is a
different column and still in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 20:55:00 +02:00
parent 24c8d71b7e
commit 46112e1221
11 changed files with 324 additions and 54 deletions

View file

@ -171,6 +171,10 @@ no request ever leaving the browser.
## Registration invitations
`registration_invite_only` sits between open and closed registration: people may
register, but only with a code. It is subordinate to `registration_enabled` — with
registration disabled entirely, nobody can register, code or not.
`registration_invites` holds codes that let someone register while
`registration_invite_only` is on. Only the hash is stored; the code is shown
once, at creation.

View file

@ -28,7 +28,20 @@ The voice preview button calls LiteLLM TTS and plays the returned audio in the b
## Nextcloud WebDAV
Users can connect a Nextcloud account with an app password and export generated notes to it.
Two ways to connect. **Sign in with Nextcloud** uses Nextcloud's own Login Flow
v2: the person enters their server address, signs in on Nextcloud itself the way
they normally do — SSO, 2FA, a password manager — and Nextcloud generates an app
password for this app. We never see their real password and they never have to
find the app-password screen. An app password can still be pasted in directly,
under *Use an app password instead*.
The poll token Nextcloud issues is a credential, so the polling happens on the
server; the browser holds only an opaque handle bound to its own account. Both
URLs the server hands back are checked and must be on the host the person typed.
Once connected, generated notes and rendered resources can be saved to it. A
resource is sent as the **rendered file** — a PowerPoint or Word document,
exactly what the download would have produced — not as text.
## Documents And S3

View file

@ -0,0 +1,13 @@
// users.webdav_learning_path was the folder the Learning Hub file browser opened
// first. The browser went with Learning Hub, the Settings field that set it has
// gone, and nothing reads the column. Nextcloud itself stays — connect,
// disconnect, and exporting a note or a rendered resource all use
// nextcloud_folder, which is a different column and still in use.
exports.up = async function (pgm) {
pgm.sql('ALTER TABLE users DROP COLUMN IF EXISTS webdav_learning_path');
};
exports.down = async function (pgm) {
pgm.sql('ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL');
};

View file

@ -30,7 +30,6 @@
<input type="checkbox" id="admin-invite-only">
Require an invitation code to register
</label>
<p style="margin:0;font-size:12px;color:var(--g500);">Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.</p>
</div>
</div>

View file

@ -121,31 +121,36 @@
<!-- Nextcloud -->
<div class="settings-section card" data-feature="nextcloud">
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>
<p>Export generated documents to your Nextcloud.</p>
<p>Save generated notes and teaching material to your own Nextcloud.</p>
<div id="nc-status">Not connected</div>
<div class="form-group">
<label>Nextcloud URL</label>
<label for="nc-url">Nextcloud address</label>
<input type="text" id="nc-url" placeholder="https://cloud.example.com">
</div>
<div class="form-group">
<label>Username</label>
<input type="text" id="nc-user" placeholder="your-username">
</div>
<div class="form-group">
<label>App Password</label>
<input type="password" id="nc-pass" placeholder="Generate in Nextcloud → Settings → Security">
<small>Go to Nextcloud → Settings → Security → Create new app password</small>
</div>
<button id="btn-nc-connect" class="btn-sm btn-primary">Connect</button>
<button id="btn-nc-disconnect" class="btn-sm btn-ghost hidden">Disconnect</button>
<div id="nc-webdav-path-section" class="hidden" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--g100);">
<label style="display:block;font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;">Learning Hub — Default Browse Path</label>
<small style="display:block;color:var(--g500);font-size:12px;margin-bottom:6px;">Folder opened first when picking files for AI content generation (e.g. <code>/Medical-Resources</code>)</small>
<div style="display:flex;gap:8px;">
<input type="text" id="nc-webdav-path" placeholder="/Medical-Resources" style="flex:1;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<button id="btn-nc-save-path" class="btn-sm btn-primary">Save Path</button>
<!-- The ordinary way in: sign in on Nextcloud itself, however you
normally do, and it hands back a password made for this app. -->
<button id="btn-nc-login-flow" class="btn-sm btn-primary">
<i class="fas fa-arrow-up-right-from-square"></i> Sign in with Nextcloud
</button>
<p id="nc-login-flow-status" role="status" style="margin:6px 0 0;font-size:12px;color:var(--g600);"></p>
<details id="nc-manual" style="margin-top:12px;">
<summary style="cursor:pointer;font-size:13px;color:var(--g600);">Use an app password instead</summary>
<div class="form-group" style="margin-top:10px;">
<label for="nc-user">Username</label>
<input type="text" id="nc-user" placeholder="your-username">
</div>
</div>
<div class="form-group">
<label for="nc-pass">App password</label>
<input type="password" id="nc-pass" placeholder="Generate in Nextcloud → Settings → Security">
<small>Nextcloud → Settings → Security → Create new app password</small>
</div>
<button id="btn-nc-connect" class="btn-sm btn-primary">Connect</button>
</details>
<button id="btn-nc-disconnect" class="btn-sm btn-ghost hidden" style="margin-top:10px;">Disconnect</button>
</div>
<!-- My Templates / Memories -->

View file

@ -16,21 +16,14 @@
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
var pathSection = document.getElementById('nc-webdav-path-section');
if (data.user && data.user.nextcloud_url) {
setNextcloudConnectedStatus(data.user.nextcloud_url, data.user.nextcloud_user);
document.getElementById('nc-url').value = data.user.nextcloud_url;
document.getElementById('nc-user').value = data.user.nextcloud_user;
document.getElementById('btn-nc-disconnect').classList.remove('hidden');
if (pathSection) {
pathSection.classList.remove('hidden');
var pathEl = document.getElementById('nc-webdav-path');
if (pathEl) pathEl.value = data.user.webdav_learning_path || '';
}
} else {
document.getElementById('nc-status').textContent = 'Not connected';
document.getElementById('btn-nc-disconnect').classList.add('hidden');
if (pathSection) pathSection.classList.add('hidden');
}
});
}
@ -70,13 +63,60 @@
.then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); });
});
document.getElementById('btn-nc-save-path').addEventListener('click', function() {
var p = (document.getElementById('nc-webdav-path').value || '').trim();
fetch('/api/user/webdav-path', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ path: p })
}).then(function(r) { return r.json(); })
.then(function(d) { d.success ? showToast('Path saved', 'success') : showToast(d.error || 'Failed', 'error'); })
.catch(function() { showToast('Failed to save', 'error'); });
// ── Sign in with Nextcloud ────────────────────────────────────────────
// Nextcloud's own login flow. The tab is opened from the click itself, before
// any await, or a popup blocker eats it — the request that fetches the URL is
// allowed to finish afterwards and point the already-open tab at it.
var pollTimer = null;
function stopPolling(message, tone) {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
var status = document.getElementById('nc-login-flow-status');
if (status) {
status.textContent = message || '';
status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
}
document.getElementById('btn-nc-login-flow').addEventListener('click', function () {
var url = (document.getElementById('nc-url').value || '').trim();
if (!url) { showToast('Enter your Nextcloud address first', 'error'); return; }
var tab = window.open('', '_blank'); // claimed while the click is still trusted
stopPolling('Opening Nextcloud…');
fetch('/api/nextcloud/login-flow/start', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ nextcloudUrl: url })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not start sign-in');
if (tab) tab.location.href = data.loginUrl; else window.open(data.loginUrl, '_blank');
stopPolling('Waiting for you to finish signing in…');
var until = Date.now() + 10 * 60 * 1000;
pollTimer = setInterval(function () {
if (Date.now() > until) return stopPolling('Sign-in timed out. Try again.', 'bad');
fetch('/api/nextcloud/login-flow/poll', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ handle: data.handle })
})
.then(function (r) { return r.json(); })
.then(function (result) {
if (result.connected) {
stopPolling('Connected as ' + result.username + '.', 'good');
loadNextcloudStatus();
showToast('Nextcloud connected', 'success');
} else if (!result.success) {
stopPolling(result.error || 'Sign-in failed.', 'bad');
}
})
.catch(function () { /* a dropped poll is not a failed sign-in */ });
}, 3000);
})
.catch(function (err) {
if (tab) tab.close();
stopPolling(err.message, 'bad');
});
});
console.log('✅ Nextcloud module loaded');

View file

@ -348,18 +348,6 @@ app.use('/api', require('./src/routes/dontMiss'));
app.use('/api', require('./src/routes/patientEducation'));
app.use('/api/user', require('./src/routes/userPreferences'));
// User-level preference: the Nextcloud folder this account browses from.
(function() {
var { authMiddleware } = require('./src/middleware/auth');
var db = require('./src/db/database');
app.post('/api/user/webdav-path', authMiddleware, require('./src/utils/policy').requireFeature('nextcloud'), async function(req, res) {
try {
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [req.body.path || null, req.user.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
})();
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');

View file

@ -226,9 +226,6 @@ async function initDatabase() {
// Add updated_by column to app_settings if upgrading
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
// The Nextcloud folder this account browses from. Named for the Learning
// Hub it was added for; kept because Settings still uses it to pick a folder.
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
// Add user preferences for STT model and TTS voice
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}

View file

@ -14,6 +14,129 @@ function davRoot(baseUrl, username) {
return baseUrl + '/remote.php/dav/files/' + encodeURIComponent(username);
}
// ── Login Flow v2 ───────────────────────────────────────────
// Connecting without asking anyone to find the app-password screen.
//
// Nextcloud's own flow: we ask the server to start one, it gives back a URL and
// a poll token. The person opens that URL, signs in the way they normally do —
// SSO, 2FA, a password manager — and Nextcloud generates an app password for
// us. We never see their real password, and they never have to know what an app
// password is.
//
// The poll token is a credential, so the polling happens here rather than in the
// browser. The browser holds only an opaque handle bound to its own account.
//
// In memory, not in the database: a flow lives for minutes, and a restart
// mid-login is a retry, not a loss. One flow per account at a time — starting a
// second replaces the first, which is what "I clicked it again" means.
var loginFlows = new Map();
var LOGIN_FLOW_TTL_MS = 20 * 60 * 1000; // Nextcloud expires its side at ~20 minutes
function sweepLoginFlows() {
var now = Date.now();
for (var [key, flow] of loginFlows) if (flow.expires < now) loginFlows.delete(key);
}
router.post('/nextcloud/login-flow/start', authMiddleware, async function (req, res) {
try {
var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, '');
if (!cleanUrl) return res.status(400).json({ error: 'Enter your Nextcloud address' });
await assertSafeHttpsUrl(cleanUrl, 'Nextcloud URL');
var started = await axios({
method: 'POST', url: cleanUrl + '/index.php/login/v2',
headers: { 'User-Agent': 'Ped-AI' }, timeout: 15000, maxRedirects: 0
});
var poll = started.data && started.data.poll;
var loginUrl = started.data && started.data.login;
if (!poll || typeof poll.token !== 'string' || typeof poll.endpoint !== 'string' || typeof loginUrl !== 'string') {
return res.status(502).json({ error: 'That address did not answer like a Nextcloud server.' });
}
// The server chooses both URLs, so both are checked. An endpoint pointing
// somewhere else would turn this into a request-forgery gadget aimed at
// whatever it named, with our credentials attached.
await assertSafeHttpsUrl(poll.endpoint, 'Nextcloud poll endpoint');
await assertSafeHttpsUrl(loginUrl, 'Nextcloud login URL');
var origin = new URL(cleanUrl).host;
if (new URL(poll.endpoint).host !== origin || new URL(loginUrl).host !== origin) {
return res.status(502).json({ error: 'That server pointed the login somewhere else. Not continuing.' });
}
sweepLoginFlows();
var handle = require('crypto').randomUUID();
loginFlows.set(handle, {
owner: req.user.id, url: cleanUrl, token: poll.token, endpoint: poll.endpoint,
folder: String(req.body.folder || '/PediatricScribe').replace(/\/+$/, ''),
expires: Date.now() + LOGIN_FLOW_TTL_MS
});
// One at a time per account.
for (var [key, flow] of loginFlows) if (flow.owner === req.user.id && key !== handle) loginFlows.delete(key);
res.json({ success: true, handle: handle, loginUrl: loginUrl });
} catch (err) {
if (err.statusCode) return res.status(err.statusCode).json({ error: err.message });
return res.status(502).json({ error: 'Could not reach that Nextcloud server.' });
}
});
router.post('/nextcloud/login-flow/poll', authMiddleware, async function (req, res) {
try {
sweepLoginFlows();
var flow = loginFlows.get(String(req.body.handle || ''));
// Bound to the account that started it: a handle is not a bearer token.
if (!flow || flow.owner !== req.user.id) {
return res.status(410).json({ error: 'That sign-in attempt has expired. Start again.' });
}
var answer;
try {
answer = await axios({
method: 'POST', url: flow.endpoint,
data: 'token=' + encodeURIComponent(flow.token),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 15000, maxRedirects: 0
});
} catch (e) {
// 404 is Nextcloud's "not finished yet", which is the normal case while
// someone is still typing their password.
var status = e.response && e.response.status;
if (status === 404) return res.json({ success: true, pending: true });
throw e;
}
var appPassword = answer.data && answer.data.appPassword;
var loginName = answer.data && answer.data.loginName;
if (!appPassword || !loginName) return res.json({ success: true, pending: true });
// Nextcloud returns the server it actually signed them into; trust that over
// what was typed, but only once it has passed the same checks.
var serverUrl = String(answer.data.server || flow.url).replace(/\/+$/, '');
await assertSafeHttpsUrl(serverUrl, 'Nextcloud URL');
var folder = flow.folder || '/PediatricScribe';
var parts = folder.split('/').filter(Boolean);
var walked = '';
for (var part of parts) {
walked += '/' + part;
try {
await axios({ method: 'MKCOL', url: davRoot(serverUrl, loginName) + walked + '/',
auth: { username: loginName, password: appPassword }, timeout: 15000, maxRedirects: 0 });
} catch (e) {}
}
await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
[serverUrl, loginName, cryptoUtil.encryptString(appPassword), folder, req.user.id]);
loginFlows.delete(String(req.body.handle));
logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud via login flow', req, { category: 'integration' });
res.json({ success: true, connected: true, username: loginName, folder: folder });
} catch (err) {
if (err.statusCode) return res.status(err.statusCode).json({ error: err.message });
return res.status(502).json({ error: 'Could not finish connecting to Nextcloud.' });
}
});
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
try {
var { nextcloudUrl, username, appPassword, folder } = req.body;

View file

@ -0,0 +1,90 @@
// Nextcloud's own Login Flow v2: the person signs in on Nextcloud however they
// normally do — SSO, 2FA, a password manager — and Nextcloud hands back an app
// password it generated. We never see their real password and they never have
// to find the app-password screen.
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');
const route = read('src/routes/nextcloud.js');
const ui = read('public/js/nextcloud.js');
const start = route.slice(route.indexOf("router.post('/nextcloud/login-flow/start'"),
route.indexOf("router.post('/nextcloud/login-flow/poll'"));
const poll = route.slice(route.indexOf("router.post('/nextcloud/login-flow/poll'"),
route.indexOf("router.post('/nextcloud/connect'"));
test('the URLs the server hands back are checked, not followed on trust', () => {
// The remote server chooses both the login URL and the poll endpoint. An
// endpoint pointing elsewhere would make this a request-forgery gadget aimed
// at whatever it named, with our credentials attached.
assert.match(start, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/);
assert.match(start, /assertSafeHttpsUrl\(poll\.endpoint, 'Nextcloud poll endpoint'\)/);
assert.match(start, /assertSafeHttpsUrl\(loginUrl, 'Nextcloud login URL'\)/);
// And both must be on the host the person actually typed.
assert.match(start, /new URL\(poll\.endpoint\)\.host !== origin \|\| new URL\(loginUrl\)\.host !== origin/);
assert.match(start, /pointed the login somewhere else/);
});
test('the poll token never reaches the browser', () => {
// It is a credential. The browser gets an opaque handle instead.
assert.match(start, /res\.json\(\{ success: true, handle: handle, loginUrl: loginUrl \}\)/);
assert.doesNotMatch(start, /res\.json\([^)]*token/);
const flowHandler = ui.slice(ui.indexOf('btn-nc-login-flow'));
assert.match(flowHandler, /handle: data\.handle/);
// The manual fallback above it does send an app password — that is its whole
// point. This path must not.
assert.doesNotMatch(flowHandler, /poll\.token|appPassword/);
});
test('a handle belongs to the account that started the flow', () => {
assert.match(poll, /!flow \|\| flow\.owner !== req\.user\.id/);
assert.match(poll, /a handle is not a bearer token/);
});
test('"not finished yet" is a normal answer, not a failure', () => {
// Nextcloud answers 404 while the person is still typing their password.
assert.match(poll, /if \(status === 404\) return res\.json\(\{ success: true, pending: true \}\)/);
});
test('the server Nextcloud reports is re-checked before it is stored', () => {
assert.match(poll, /answer\.data\.server \|\| flow\.url/);
assert.match(poll, /assertSafeHttpsUrl\(serverUrl, 'Nextcloud URL'\)/);
});
test('the app password is encrypted at rest, like every other credential here', () => {
assert.match(poll, /cryptoUtil\.encryptString\(appPassword\)/);
});
test('a flow expires, and starting again replaces the old one', () => {
assert.match(route, /LOGIN_FLOW_TTL_MS = 20 \* 60 \* 1000/);
assert.match(route, /function sweepLoginFlows\(\)/);
assert.match(start, /flow\.owner === req\.user\.id && key !== handle\) loginFlows\.delete\(key\)/);
});
test('the tab is opened while the click is still trusted', () => {
// Opening after an await is what a popup blocker stops.
const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
const open = handler.indexOf("window.open('', '_blank')");
const fetchAt = handler.indexOf("fetch('/api/nextcloud/login-flow/start'");
assert.ok(open > -1 && open < fetchAt, 'the tab must be claimed before the request');
assert.match(handler, /a popup blocker eats it|still trusted/);
});
test('polling stops: on success, on failure, and on a deadline', () => {
const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
assert.match(handler, /Date\.now\(\) > until\) return stopPolling/);
assert.match(handler, /if \(result\.connected\)/);
// stopPolling owns the interval, and is what every exit calls.
assert.match(ui, /function stopPolling\(message, tone\)[\s\S]{0,160}clearInterval\(pollTimer\)/);
// A dropped poll is not a failed sign-in.
assert.match(handler, /a dropped poll is not a failed sign-in/);
});
test('the app-password path is still there, as the fallback', () => {
assert.match(route, /router\.post\('\/nextcloud\/connect'/);
const settings = read('public/components/settings.html');
assert.match(settings, /Use an app password instead/);
assert.match(settings, /id="btn-nc-login-flow"/);
});

View file

@ -355,8 +355,7 @@ test('feature routes deny before data/provider access; user status is nonsensiti
for (const name of ['read_aloud', 'nextcloud', 'memories']) f.state.settings['feature.' + name] = 'false';
const endpoints = [
['POST', '/api/text-to-speech'], ['POST', '/api/nextcloud/connect'], ['POST', '/api/nextcloud/export'], ['POST', '/api/nextcloud/disconnect'],
['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'],
['POST', '/api/user/webdav-path']
['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1']
];
for (const [method, route] of endpoints) {
const response = await request(route, { method, authenticated: true, body: method === 'GET' ? undefined : { webdavPath: '/synthetic.txt', text: 'Synthetic' } });
@ -457,7 +456,6 @@ test('enabled personal Nextcloud and memory CRUD remain usable through actual ha
for (const [route, body] of [
['/api/nextcloud/connect', { nextcloudUrl: 'https://cloud.example', username: 'synthetic', appPassword: 'synthetic-token' }],
['/api/nextcloud/export', { content: 'Synthetic document', type: 'note' }],
['/api/user/webdav-path', { path: '/Synthetic' }],
['/api/nextcloud/disconnect', {}]
]) {
const response = await request(route, { method: 'POST', role: 'user', body });