javascript-solid-server 0.0.174 → 0.0.176

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/jss.js CHANGED
@@ -773,11 +773,15 @@ accountCmd
773
773
 
774
774
  if (options.purge) {
775
775
  const dataRoot = process.env.DATA_ROOT || './data';
776
- const podPath = path.join(dataRoot, account.username);
776
+ // Use podName, not username — createAccount lowercases the
777
+ // username but pod directories on disk preserve the original
778
+ // case. On case-sensitive filesystems they can differ.
779
+ const podPath = path.join(dataRoot, account.podName || account.username);
777
780
  await fs.remove(podPath);
778
781
  console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`);
779
782
  } else {
780
- console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${account.username}/ (use --purge to remove).\n`);
783
+ const podDir = account.podName || account.username;
784
+ console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${podDir}/ (use --purge to remove).\n`);
781
785
  }
782
786
  } catch (err) {
783
787
  console.error(`Error: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.174",
3
+ "version": "0.0.176",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -5,9 +5,12 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+ import { authenticate, findByUsername, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
9
11
  import { getJwks } from './keys.js';
10
12
  import { getWebIdFromRequestAsync } from '../auth/token.js';
13
+ import { accountDeletePage } from './views.js';
11
14
 
12
15
  /**
13
16
  * Handle POST /idp/credentials
@@ -272,6 +275,286 @@ export async function handleChangePassword(request, reply) {
272
275
  };
273
276
  }
274
277
 
278
+ /**
279
+ * Handle DELETE /idp/account (#352)
280
+ *
281
+ * Owner-initiated account deletion. Authenticated caller proves
282
+ * possession via re-entering currentPassword (matches the
283
+ * password-rotation pattern in #351). Optional `purgeData: true` also
284
+ * removes the pod's filesystem tree at `<dataRoot>/<podName>/` (falling
285
+ * back to `<username>` only if podName is absent on the account record).
286
+ *
287
+ * Failure modes:
288
+ * 401 — unauthenticated, or wrong currentPassword
289
+ * 400 — invalid request body / missing password
290
+ * 403 — single-user mode (deletion would brick the server until
291
+ * re-seed; operator should use the CLI), or no account for the
292
+ * caller's WebID. The "no account" case lands here rather than
293
+ * 404 because the caller had a valid token — they're proving
294
+ * identity, just not for an account this server holds.
295
+ *
296
+ * Out of scope: invalidating in-flight access tokens. Tokens reference
297
+ * the WebID; once the account record is gone, follow-up auth attempts
298
+ * fail at findByWebId(). Existing bearer tokens that don't round-trip
299
+ * through findByWebId() will appear valid until they expire — same
300
+ * shape as the password-change endpoint.
301
+ *
302
+ * @param {object} request - Fastify request
303
+ * @param {object} reply - Fastify reply
304
+ * @param {object} options
305
+ * @param {boolean} [options.singleUser] - When true, the endpoint
306
+ * refuses (deletion would leave the server with no IDP account).
307
+ */
308
+ export async function handleDeleteAccount(request, reply, options = {}) {
309
+ // Single-user mode: deletion via HTTP is blocked. The single-user
310
+ // pod has exactly one account; deleting it bricks the server until
311
+ // re-seed. The CLI (`jss account delete`) stays available for the
312
+ // operator who has filesystem access.
313
+ if (options.singleUser) {
314
+ return reply.code(403).send({
315
+ error: 'forbidden',
316
+ error_description: 'Account deletion via HTTP is disabled in single-user mode. Use the `jss account delete` CLI on the server.',
317
+ });
318
+ }
319
+
320
+ // 1. Authenticate caller
321
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
322
+ if (!webId) {
323
+ return reply.code(401).send({
324
+ error: 'invalid_token',
325
+ error_description: authError || 'Authentication required',
326
+ });
327
+ }
328
+
329
+ // 2. Parse body — same flexible shape as handleChangePassword
330
+ let body = request.body;
331
+ if (Buffer.isBuffer(body)) body = body.toString('utf-8');
332
+ if (typeof body === 'string') {
333
+ try { body = JSON.parse(body); } catch { body = {}; }
334
+ }
335
+ const currentPassword = body?.currentPassword;
336
+ const purgeData = body?.purgeData === true;
337
+
338
+ if (typeof currentPassword !== 'string' || !currentPassword) {
339
+ return reply.code(400).send({
340
+ error: 'invalid_request',
341
+ error_description: 'currentPassword is required (string)',
342
+ });
343
+ }
344
+
345
+ // 3. Resolve account from caller's WebID
346
+ const account = await findByWebId(webId);
347
+ if (!account) {
348
+ return reply.code(403).send({
349
+ error: 'forbidden',
350
+ error_description: 'No account found for authenticated WebID',
351
+ });
352
+ }
353
+
354
+ // 4. Verify currentPassword (re-auth proof)
355
+ if (!(await verifyPassword(account, currentPassword))) {
356
+ return reply.code(401).send({
357
+ error: 'invalid_grant',
358
+ error_description: 'Current password is incorrect',
359
+ });
360
+ }
361
+
362
+ // 5. Delete via the shared helper (also handles optional pod-data purge).
363
+ // See deleteAccountAndOptionallyPurge below for the purge semantics
364
+ // and rationale (#391 pass 2 / pass 3).
365
+ const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);
366
+
367
+ reply.header('Cache-Control', 'no-store');
368
+ reply.header('Pragma', 'no-cache');
369
+ return {
370
+ ok: true,
371
+ webid: account.webId,
372
+ purged,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Apply anti-clickjacking + no-store cache headers to a response.
378
+ * Used on every account-deletion HTML response (form, success, error
379
+ * re-render, disabled-message page) and on the corresponding routes.
380
+ *
381
+ * - Cache-Control: no-store — destructive form/success page should
382
+ * never sit in a shared cache; if a token gets shared via the URL
383
+ * the browser shouldn't replay the action from cache either.
384
+ * - X-Frame-Options + frame-ancestors — block clickjacking.
385
+ * Embedding this form in a hostile iframe and tricking a user into
386
+ * submitting a captured action is exactly the threat shape these
387
+ * headers exist to mitigate.
388
+ */
389
+ export function setNoCacheClickjackHeaders(reply) {
390
+ reply.header('Cache-Control', 'no-store');
391
+ reply.header('Pragma', 'no-cache');
392
+ reply.header('X-Frame-Options', 'DENY');
393
+ reply.header('Content-Security-Policy', "frame-ancestors 'none'");
394
+ }
395
+
396
+ /**
397
+ * Internal: delete an account record + optional pod-data purge.
398
+ * Shared between the JSON endpoint (handleDeleteAccount) and the
399
+ * form-driven endpoint (handleAccountDeleteForm in #392).
400
+ *
401
+ * Best-effort purge — fs.remove can throw, but the account is already
402
+ * gone and we want a clean signal rather than a 500. Path is derived
403
+ * from account.podName (NOT username, which createAccount lowercases —
404
+ * pod dir on disk is original case, see #391 pass 2). Belt-and-
405
+ * suspenders path-relative check rejects ../traversal and works at FS
406
+ * roots (see #391 pass 3).
407
+ *
408
+ * @param {object} request - Fastify request, used only for log access
409
+ * @param {object} account - Account record (with username, podName, webId)
410
+ * @param {boolean} purgeData - If true, also remove the pod's filesystem tree
411
+ * @returns {Promise<{purged: boolean}>}
412
+ */
413
+ async function deleteAccountAndOptionallyPurge(request, account, purgeData) {
414
+ await deleteAccount(account.id);
415
+
416
+ let purged = false;
417
+ if (purgeData) {
418
+ const dataRoot = process.env.DATA_ROOT || './data';
419
+ const candidate = path.resolve(dataRoot, account.podName || account.username);
420
+ const root = path.resolve(dataRoot);
421
+ const rel = path.relative(root, candidate);
422
+ const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
423
+ if (isProperChild) {
424
+ try {
425
+ await fs.remove(candidate);
426
+ purged = true;
427
+ } catch (err) {
428
+ request.log.error({ err, path: candidate, username: account.username },
429
+ 'Pod data purge failed after account deletion');
430
+ }
431
+ } else {
432
+ // Belt-and-suspenders rejection — shouldn't trigger on registered
433
+ // pod names but logging it surfaces config drift (e.g. someone
434
+ // changed dataRoot, podName field is malformed, etc.) and makes
435
+ // the "purge did not complete" UX message diagnosable from the
436
+ // operator's logs without leaking the path to the client.
437
+ request.log.warn({ path: candidate, dataRoot: root, podName: account.podName, username: account.username },
438
+ 'Pod data purge skipped: candidate path is not a proper child of dataRoot');
439
+ }
440
+ }
441
+ return { purged };
442
+ }
443
+
444
+ /**
445
+ * Handle POST /idp/account/delete (#392) — form-driven account deletion.
446
+ *
447
+ * Public unauthenticated endpoint that takes a form-encoded body with
448
+ * username + currentPassword + confirmUsername (+ optional keepData
449
+ * opt-out checkbox; default behavior is purge-on for the leaving-user
450
+ * UX, opposite the JSON endpoint's purge-off default). Authenticates
451
+ * the user via password directly (no Bearer token round-trip), validates
452
+ * the destructive-action UX guard, then calls into the same delete
453
+ * logic as the JSON endpoint via deleteAccountAndOptionallyPurge.
454
+ * Returns HTML directly:
455
+ * - success → success page
456
+ * - any failure → the form re-rendered with an error message and the
457
+ * username field pre-filled (no redirect — single response, status
458
+ * 200 with the rendered form)
459
+ *
460
+ * GET /idp/account/delete is a separate route that just renders the
461
+ * form via accountDeletePage(); see src/idp/index.js. This handler is
462
+ * POST-only.
463
+ *
464
+ * Single-user mode: this handler short-circuits to the disabled-message
465
+ * page (matches the GET route's behavior). Same policy as the JSON
466
+ * endpoint, which 403s with the equivalent message.
467
+ *
468
+ * @param {object} request - Fastify request
469
+ * @param {object} reply - Fastify reply
470
+ * @param {object} options
471
+ * @param {boolean} [options.singleUser] - Single-user mode flag
472
+ */
473
+ export async function handleAccountDeleteForm(request, reply, options = {}) {
474
+ // Every response from this handler is a destructive-action surface
475
+ // that re-takes the user's password — never cache, never embed.
476
+ setNoCacheClickjackHeaders(reply);
477
+
478
+ if (options.singleUser) {
479
+ // 403 matches the GET route, /idp/register's disabled-route policy,
480
+ // and the JSON DELETE endpoint's 403 — consistent status across
481
+ // every disabled-in-single-user surface.
482
+ return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true }));
483
+ }
484
+
485
+ // Parse form-encoded body. JSS registers a wildcard parseAs:'buffer'
486
+ // content parser (server.js:190), so request.body arrives here as a
487
+ // Buffer. We coerce to a string and parse the application/x-www-form-
488
+ // urlencoded shape via URLSearchParams. (No @fastify/formbody is
489
+ // installed; doing it inline keeps this self-contained and matches
490
+ // what handleChangePassword does for JSON.)
491
+ let body = request.body;
492
+ if (Buffer.isBuffer(body)) body = body.toString('utf-8');
493
+ if (typeof body === 'string') {
494
+ try {
495
+ const params = new URLSearchParams(body);
496
+ body = Object.fromEntries(params);
497
+ } catch { body = {}; }
498
+ }
499
+
500
+ const username = (body?.username || '').trim();
501
+ const currentPassword = body?.currentPassword || '';
502
+ const confirmUsername = (body?.confirmUsername || '').trim();
503
+ // Form field is `keepData` (inverse of the JSON endpoint's `purgeData`)
504
+ // so the form's default is purge-on: a user who is leaving the server
505
+ // probably wants their files gone too. Checking "Keep my pod data" is
506
+ // the opt-out. JSON endpoint (DELETE /idp/account) keeps the
507
+ // explicit `purgeData` shape that matches the CLI's default-off
508
+ // semantics for operator scripts; the form intentionally diverges.
509
+ const keepData = body?.keepData === 'on' || body?.keepData === true;
510
+ const purgeData = !keepData;
511
+
512
+ if (!username || !currentPassword || !confirmUsername) {
513
+ return reply.type('text/html').send(accountDeletePage({
514
+ error: 'All fields are required.',
515
+ username,
516
+ }));
517
+ }
518
+
519
+ // Destructive-action UX guard: the user must type the same string
520
+ // twice. The string-equality check is case-sensitive on the typed
521
+ // form values; that's purely the typing-it-again confirmation
522
+ // pattern (catch typos / accidental submits). Note: findByUsername()
523
+ // lowercases internally, so "Alice" + "Alice" both resolve to the
524
+ // same account record as "alice" — the case-sensitive comparison
525
+ // here doesn't gate which record gets deleted, only whether the
526
+ // user typed the same thing twice.
527
+ if (username !== confirmUsername) {
528
+ return reply.type('text/html').send(accountDeletePage({
529
+ error: 'Confirmation does not match the username you entered.',
530
+ username,
531
+ }));
532
+ }
533
+
534
+ // Look up + verify password without side effects. authenticate() is
535
+ // tempting (looks up + verifies in one call) but writes lastLogin on
536
+ // success — wrong shape for a destructive proof-of-possession check
537
+ // (and would fail the deletion if the account file weren't writable).
538
+ // Mirrors handleChangePassword / handleDeleteAccount which both use
539
+ // verifyPassword for the same reason.
540
+ const account = await findByUsername(username);
541
+ if (!account || !(await verifyPassword(account, currentPassword))) {
542
+ return reply.type('text/html').send(accountDeletePage({
543
+ error: 'Username or password is incorrect.',
544
+ username,
545
+ }));
546
+ }
547
+
548
+ const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);
549
+
550
+ // If the user asked for a purge but it didn't run (fs.remove threw,
551
+ // path-relative check rejected, etc.), surface that on the success
552
+ // page. Account deletion succeeded — don't roll that back — but the
553
+ // operator may need to finish cleanup. Don't leak server paths.
554
+ const purgeFailed = purgeData && !purged;
555
+ return reply.type('text/html').send(accountDeletePage({ success: true, purgeFailed }));
556
+ }
557
+
275
558
  /**
276
559
  * Handle GET /idp/credentials
277
560
  * Returns info about the credentials endpoint
package/src/idp/index.js CHANGED
@@ -23,10 +23,13 @@ import {
23
23
  handleCredentials,
24
24
  handleCredentialsInfo,
25
25
  handleChangePassword,
26
+ handleDeleteAccount,
27
+ handleAccountDeleteForm,
28
+ setNoCacheClickjackHeaders,
26
29
  } from './credentials.js';
27
30
  import * as passkey from './passkey.js';
28
31
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
29
- import { landingPage } from './views.js';
32
+ import { landingPage, accountDeletePage } from './views.js';
30
33
 
31
34
  /**
32
35
  * IdP Fastify Plugin
@@ -279,6 +282,51 @@ export async function idpPlugin(fastify, options) {
279
282
  return handleChangePassword(request, reply);
280
283
  });
281
284
 
285
+ // DELETE account - authenticated owner deletes their own account (#352).
286
+ // Single-user mode is rejected at the handler (deletion would leave the
287
+ // server with no IDP account until re-seed; CLI is the operator path).
288
+ fastify.delete('/idp/account', {
289
+ config: {
290
+ rateLimit: {
291
+ max: 5,
292
+ timeWindow: '1 minute',
293
+ keyGenerator: (request) => request.ip
294
+ }
295
+ }
296
+ }, async (request, reply) => {
297
+ return handleDeleteAccount(request, reply, { singleUser });
298
+ });
299
+
300
+ // GET account-delete form (#392) - human-friendly UI for #352. Public
301
+ // unauthenticated page; auth happens at form submission via password.
302
+ // Single-user mode returns 403 to stay consistent with /idp/register's
303
+ // disabled-route policy and the JSON DELETE /idp/account endpoint
304
+ // (which also 403s in single-user mode). Body is still HTML so a
305
+ // browser visitor sees the explanation. Every response sets
306
+ // anti-clickjacking + no-store headers — destructive-action page.
307
+ fastify.get('/idp/account/delete', async (request, reply) => {
308
+ setNoCacheClickjackHeaders(reply);
309
+ if (singleUser) {
310
+ return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true }));
311
+ }
312
+ return reply.type('text/html').send(accountDeletePage({ singleUser: false }));
313
+ });
314
+
315
+ // POST account-delete form (#392) - processes the form submission.
316
+ // Same rate-limit as the JSON endpoint to keep the destructive-action
317
+ // surface consistent across both paths.
318
+ fastify.post('/idp/account/delete', {
319
+ config: {
320
+ rateLimit: {
321
+ max: 5,
322
+ timeWindow: '1 minute',
323
+ keyGenerator: (request) => request.ip
324
+ }
325
+ }
326
+ }, async (request, reply) => {
327
+ return handleAccountDeleteForm(request, reply, { singleUser });
328
+ });
329
+
282
330
  // Interaction routes (our custom login/consent UI)
283
331
  // These bypass oidc-provider and use our handlers
284
332
 
package/src/idp/views.js CHANGED
@@ -533,6 +533,194 @@ export function consentPage(uid, client, params, account) {
533
533
  `;
534
534
  }
535
535
 
536
+ /**
537
+ * Account-deletion form HTML (#392).
538
+ *
539
+ * Public unauthenticated page (matches the existing /idp landing and
540
+ * /idp/register pattern). Auth happens at submission time: the user
541
+ * supplies username + password, which the server validates and uses as
542
+ * proof-of-possession for the delete. The "type your username again to
543
+ * confirm" field is the destructive-action UX guard.
544
+ *
545
+ * On any failure (wrong password, mismatched confirmation, etc.) the
546
+ * handler re-renders this same form in place at status 200 with an
547
+ * error message and the identifier field pre-filled — no redirect.
548
+ *
549
+ * @param {object} opts
550
+ * @param {string|null} opts.error - Error message (e.g. wrong password) to display
551
+ * @param {string} opts.username - Pre-fill the identifier field on re-render after error
552
+ * @param {boolean} opts.singleUser - When true, render a disabled message
553
+ * instead of the form. Deletion via HTTP is blocked in single-user mode
554
+ * (would brick the IdP until re-seed); operator path stays the CLI.
555
+ * @param {boolean} opts.success - When true, render the post-delete confirmation
556
+ * @param {boolean} opts.purgeFailed - When true (only on success), include a
557
+ * notice that the user requested a pod-data purge but it didn't complete.
558
+ * Account deletion still succeeded.
559
+ */
560
+ export function accountDeletePage({ error = null, username = '', singleUser = false, success = false, purgeFailed = false } = {}) {
561
+ if (singleUser) {
562
+ return `
563
+ <!DOCTYPE html>
564
+ <html lang="en">
565
+ <head>
566
+ <meta charset="UTF-8">
567
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
568
+ <title>Account deletion disabled - Solid IdP</title>
569
+ <style>${styles}</style>
570
+ </head>
571
+ <body>
572
+ <div class="container">
573
+ <div class="logo">${solidLogo}</div>
574
+ <h1>Account deletion disabled</h1>
575
+ <p>This server runs in <strong>single-user mode</strong>. Deleting the single account
576
+ via HTTP would leave the server with no IdP account until re-seed,
577
+ so this endpoint is disabled.</p>
578
+ <p>The operator can still delete the account at the shell with:</p>
579
+ <pre style="background: #f1f5f9; padding: 12px; border-radius: 6px; font-size: 13px;">jss account delete &lt;username&gt;</pre>
580
+ <a href="/idp" class="btn btn-secondary" style="text-decoration: none;">Back</a>
581
+ </div>
582
+ </body>
583
+ </html>
584
+ `;
585
+ }
586
+
587
+ if (success) {
588
+ return `
589
+ <!DOCTYPE html>
590
+ <html lang="en">
591
+ <head>
592
+ <meta charset="UTF-8">
593
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
594
+ <title>Account deleted - Solid IdP</title>
595
+ <style>${styles}</style>
596
+ </head>
597
+ <body>
598
+ <div class="container">
599
+ <div class="logo">${solidLogo}</div>
600
+ <h1>Account deleted</h1>
601
+ <p>Your account record has been removed from this server. Future sign-ins
602
+ with this username will fail.</p>
603
+ <p style="font-size: 13px; color: #64748b; margin-top: 8px;">
604
+ Note: any access tokens already issued may remain usable until they
605
+ expire — the server does not currently revoke them on account deletion.
606
+ </p>
607
+ ${purgeFailed ? `
608
+ <div class="error" style="margin-top: 16px;">
609
+ Your account was deleted, but the pod-data purge did not complete on
610
+ this server. Some files may still exist. Contact the operator to
611
+ finish the cleanup if needed.
612
+ </div>
613
+ ` : ''}
614
+ <a href="/idp" class="btn btn-primary" style="text-decoration: none; margin-top: 16px;">Return to sign-in</a>
615
+ </div>
616
+ </body>
617
+ </html>
618
+ `;
619
+ }
620
+
621
+ return `
622
+ <!DOCTYPE html>
623
+ <html lang="en">
624
+ <head>
625
+ <meta charset="UTF-8">
626
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
627
+ <title>Delete account - Solid IdP</title>
628
+ <style>${styles}
629
+ .danger {
630
+ background: #fef2f2;
631
+ border: 1px solid #fecaca;
632
+ color: #991b1b;
633
+ padding: 14px 16px;
634
+ border-radius: 8px;
635
+ margin: 16px 0 24px;
636
+ font-size: 13px;
637
+ line-height: 1.55;
638
+ }
639
+ .danger strong { color: #7f1d1d; }
640
+ .btn-danger {
641
+ background: #dc2626;
642
+ color: #fff;
643
+ }
644
+ .btn-danger:hover { background: #b91c1c; }
645
+ .checkbox-row {
646
+ display: flex;
647
+ align-items: flex-start;
648
+ gap: 10px;
649
+ margin: 16px 0 8px;
650
+ padding: 10px 12px;
651
+ background: #f8fafc;
652
+ border: 1px solid #e2e8f0;
653
+ border-radius: 6px;
654
+ }
655
+ .checkbox-row input[type="checkbox"] { margin-top: 3px; flex-shrink: 0; }
656
+ .checkbox-row label {
657
+ margin: 0;
658
+ font-size: 13px;
659
+ line-height: 1.5;
660
+ color: #334155;
661
+ cursor: pointer;
662
+ }
663
+ .checkbox-row label strong { color: #0f172a; }
664
+ </style>
665
+ </head>
666
+ <body>
667
+ <div class="container">
668
+ <div class="logo">${solidLogo}</div>
669
+ <h1>Delete your account</h1>
670
+
671
+ <div class="danger">
672
+ <strong>This is permanent.</strong> Your account record and credentials will be
673
+ removed; future sign-ins with this username will fail. By default, your pod
674
+ data (every file you've stored, including your WebID profile document) is
675
+ also wiped — check the box below if you want to keep it. Federated references
676
+ (ActivityPub follows, Nostr relays, type indexes) cannot be retracted from
677
+ this server.
678
+ <br><br>
679
+ <span style="font-size: 12px; color: #7f1d1d;">
680
+ Note: access tokens already issued may remain usable until they
681
+ expire — the server does not currently revoke them on deletion.
682
+ </span>
683
+ </div>
684
+
685
+ ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
686
+
687
+ <form method="POST" action="/idp/account/delete">
688
+ <label for="username">Username</label>
689
+ <input type="text" id="username" name="username" required autofocus
690
+ value="${escapeHtml(username || '')}"
691
+ placeholder="alice">
692
+
693
+ <label for="currentPassword">Current password</label>
694
+ <input type="password" id="currentPassword" name="currentPassword" required
695
+ placeholder="Re-enter your password">
696
+
697
+ <label for="confirmUsername">Type your username again to confirm</label>
698
+ <input type="text" id="confirmUsername" name="confirmUsername" required
699
+ placeholder="Must match the username above">
700
+
701
+ <div class="checkbox-row">
702
+ <input type="checkbox" id="keepData" name="keepData" value="on">
703
+ <label for="keepData">
704
+ <strong>Keep my pod data on this server.</strong> Check only if you want
705
+ to delete just your account record and leave your files in place. Default
706
+ (unchecked) wipes the pod folder along with the account.
707
+ </label>
708
+ </div>
709
+
710
+ <button type="submit" class="btn btn-danger" style="width: 100%; margin-top: 16px;">
711
+ Delete my account permanently
712
+ </button>
713
+ </form>
714
+
715
+ <p style="text-align: center; margin-top: 16px; font-size: 13px;">
716
+ <a href="/idp">Cancel and go back</a>
717
+ </p>
718
+ </div>
719
+ </body>
720
+ </html>
721
+ `;
722
+ }
723
+
536
724
  /**
537
725
  * Error page HTML
538
726
  */
@@ -0,0 +1,635 @@
1
+ /**
2
+ * DELETE /idp/account — authenticated owner deletes their own account (#352)
3
+ */
4
+
5
+ import { describe, it, before, after } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { createServer } from '../src/server.js';
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+ import { createServer as createNetServer } from 'net';
11
+
12
+ const TEST_HOST = 'localhost';
13
+
14
+ function getAvailablePort() {
15
+ return new Promise((resolve, reject) => {
16
+ const srv = createNetServer();
17
+ srv.on('error', reject);
18
+ srv.listen(0, TEST_HOST, () => {
19
+ const port = srv.address().port;
20
+ srv.close(() => resolve(port));
21
+ });
22
+ });
23
+ }
24
+
25
+ async function createPod(baseUrl, name, email, password) {
26
+ const res = await fetch(`${baseUrl}/.pods`, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ name, email, password }),
30
+ });
31
+ const body = await res.json().catch(() => ({}));
32
+ assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
33
+ return body;
34
+ }
35
+
36
+ async function loginToken(baseUrl, email, password) {
37
+ const res = await fetch(`${baseUrl}/idp/credentials`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ email, password }),
41
+ });
42
+ const body = await res.json().catch(() => ({}));
43
+ assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
44
+ return body.access_token;
45
+ }
46
+
47
+ describe('DELETE /idp/account — self-delete', () => {
48
+ let server;
49
+ let baseUrl;
50
+ let originalDataRoot;
51
+ const DATA_DIR = './test-data-delete-account';
52
+
53
+ before(async () => {
54
+ originalDataRoot = process.env.DATA_ROOT;
55
+ await fs.remove(DATA_DIR);
56
+ await fs.ensureDir(DATA_DIR);
57
+ const port = await getAvailablePort();
58
+ baseUrl = `http://${TEST_HOST}:${port}`;
59
+ server = createServer({
60
+ logger: false,
61
+ root: DATA_DIR,
62
+ idp: true,
63
+ idpIssuer: baseUrl,
64
+ forceCloseConnections: true,
65
+ });
66
+ await server.listen({ port, host: TEST_HOST });
67
+ });
68
+
69
+ after(async () => {
70
+ await server.close();
71
+ await fs.remove(DATA_DIR);
72
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
73
+ else process.env.DATA_ROOT = originalDataRoot;
74
+ });
75
+
76
+ it('rejects unauthenticated request with 401', async () => {
77
+ const res = await fetch(`${baseUrl}/idp/account`, {
78
+ method: 'DELETE',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ currentPassword: 'whatever' }),
81
+ });
82
+ assert.strictEqual(res.status, 401);
83
+ });
84
+
85
+ it('rejects missing currentPassword with 400', async () => {
86
+ const id = `alice${Date.now()}`;
87
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
88
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
89
+
90
+ const res = await fetch(`${baseUrl}/idp/account`, {
91
+ method: 'DELETE',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ 'Authorization': `Bearer ${token}`,
95
+ },
96
+ body: JSON.stringify({}),
97
+ });
98
+ assert.strictEqual(res.status, 400);
99
+ });
100
+
101
+ it('rejects wrong currentPassword with 401, account untouched', async () => {
102
+ const id = `bob${Date.now()}`;
103
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
104
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
105
+
106
+ const res = await fetch(`${baseUrl}/idp/account`, {
107
+ method: 'DELETE',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ 'Authorization': `Bearer ${token}`,
111
+ },
112
+ body: JSON.stringify({ currentPassword: 'wrongpassword' }),
113
+ });
114
+ assert.strictEqual(res.status, 401);
115
+
116
+ // Account still works
117
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
121
+ });
122
+ assert.strictEqual(reLogin.status, 200);
123
+ });
124
+
125
+ it('happy path: deletes account; subsequent login fails with 401', async () => {
126
+ const id = `carol${Date.now()}`;
127
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
128
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
129
+
130
+ const res = await fetch(`${baseUrl}/idp/account`, {
131
+ method: 'DELETE',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ 'Authorization': `Bearer ${token}`,
135
+ },
136
+ body: JSON.stringify({ currentPassword: 'password123' }),
137
+ });
138
+ assert.strictEqual(res.status, 200);
139
+ const body = await res.json();
140
+ assert.strictEqual(body.ok, true);
141
+ assert.ok(body.webid.includes(id), 'response carries webid');
142
+ assert.strictEqual(body.purged, false, 'purgeData defaults to false');
143
+
144
+ // Login as the same user now fails
145
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
149
+ });
150
+ assert.strictEqual(reLogin.status, 401);
151
+ });
152
+
153
+ it('purgeData: true also wipes the pod filesystem tree', async () => {
154
+ const id = `dave${Date.now()}`;
155
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
156
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
157
+
158
+ // Pod tree exists before deletion
159
+ const podPath = path.join(DATA_DIR, id);
160
+ assert.strictEqual(await fs.pathExists(podPath), true,
161
+ 'pod data should exist before deletion');
162
+
163
+ const res = await fetch(`${baseUrl}/idp/account`, {
164
+ method: 'DELETE',
165
+ headers: {
166
+ 'Content-Type': 'application/json',
167
+ 'Authorization': `Bearer ${token}`,
168
+ },
169
+ body: JSON.stringify({ currentPassword: 'password123', purgeData: true }),
170
+ });
171
+ assert.strictEqual(res.status, 200);
172
+ const body = await res.json();
173
+ assert.strictEqual(body.purged, true);
174
+
175
+ // Pod tree gone
176
+ assert.strictEqual(await fs.pathExists(podPath), false,
177
+ 'pod data should be purged');
178
+ });
179
+
180
+ it('purgeData: true removes the on-disk pod dir even when it has uppercase letters', async () => {
181
+ // Regression for the bug where purge derived its path from
182
+ // account.username (which createAccount lowercases) instead of
183
+ // account.podName (which preserves the original case). On
184
+ // case-sensitive filesystems the pod dir at <dataRoot>/Greta…/
185
+ // wouldn't match the derived <dataRoot>/greta…/ path.
186
+ const id = `Greta${Date.now()}`;
187
+ await createPod(baseUrl, id, `${id.toLowerCase()}@example.com`, 'password123');
188
+ const token = await loginToken(baseUrl, `${id.toLowerCase()}@example.com`, 'password123');
189
+
190
+ const podPath = path.join(DATA_DIR, id); // mixed-case as created
191
+ assert.strictEqual(await fs.pathExists(podPath), true,
192
+ 'pod data should exist at the mixed-case path before deletion');
193
+
194
+ const res = await fetch(`${baseUrl}/idp/account`, {
195
+ method: 'DELETE',
196
+ headers: {
197
+ 'Content-Type': 'application/json',
198
+ 'Authorization': `Bearer ${token}`,
199
+ },
200
+ body: JSON.stringify({ currentPassword: 'password123', purgeData: true }),
201
+ });
202
+ assert.strictEqual(res.status, 200);
203
+ const body = await res.json();
204
+ assert.strictEqual(body.purged, true, 'purge should report success');
205
+
206
+ assert.strictEqual(await fs.pathExists(podPath), false,
207
+ 'mixed-case pod dir should be gone (regression: not stranded by username lowercasing)');
208
+ });
209
+
210
+ it('purgeData: false (default) preserves the pod filesystem tree', async () => {
211
+ const id = `frank${Date.now()}`;
212
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
213
+ const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
214
+
215
+ const podPath = path.join(DATA_DIR, id);
216
+ assert.strictEqual(await fs.pathExists(podPath), true);
217
+
218
+ const res = await fetch(`${baseUrl}/idp/account`, {
219
+ method: 'DELETE',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'Authorization': `Bearer ${token}`,
223
+ },
224
+ // Note: no purgeData flag at all
225
+ body: JSON.stringify({ currentPassword: 'password123' }),
226
+ });
227
+ assert.strictEqual(res.status, 200);
228
+
229
+ // Account is gone but pod data preserved
230
+ assert.strictEqual(await fs.pathExists(podPath), true,
231
+ 'pod data should be preserved when purgeData is omitted');
232
+ });
233
+
234
+ it('cross-account: A authenticated, sending B\'s password — fails 401, neither account touched', async () => {
235
+ const aId = `eve${Date.now()}`;
236
+ const bId = `mallory${Date.now() + 1}`;
237
+ await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123');
238
+ await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123');
239
+
240
+ const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123');
241
+
242
+ // A sends B's password — handler resolves account from A's WebID, so the
243
+ // currentPassword must match A's. With B's password it fails 401 (and
244
+ // crucially doesn't touch either account).
245
+ const res = await fetch(`${baseUrl}/idp/account`, {
246
+ method: 'DELETE',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ 'Authorization': `Bearer ${aToken}`,
250
+ },
251
+ body: JSON.stringify({ currentPassword: 'bpassword123' }),
252
+ });
253
+ assert.strictEqual(res.status, 401);
254
+
255
+ // Both accounts still functional
256
+ const aLogin = await fetch(`${baseUrl}/idp/credentials`, {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }),
260
+ });
261
+ assert.strictEqual(aLogin.status, 200);
262
+
263
+ const bLogin = await fetch(`${baseUrl}/idp/credentials`, {
264
+ method: 'POST',
265
+ headers: { 'Content-Type': 'application/json' },
266
+ body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }),
267
+ });
268
+ assert.strictEqual(bLogin.status, 200);
269
+ });
270
+ });
271
+
272
+ describe('GET/POST /idp/account/delete — HTML form (#392)', () => {
273
+ let server;
274
+ let baseUrl;
275
+ let originalDataRoot;
276
+ const DATA_DIR = './test-data-delete-form';
277
+
278
+ before(async () => {
279
+ originalDataRoot = process.env.DATA_ROOT;
280
+ await fs.remove(DATA_DIR);
281
+ await fs.ensureDir(DATA_DIR);
282
+ const port = await getAvailablePort();
283
+ baseUrl = `http://${TEST_HOST}:${port}`;
284
+ server = createServer({
285
+ logger: false,
286
+ root: DATA_DIR,
287
+ idp: true,
288
+ idpIssuer: baseUrl,
289
+ forceCloseConnections: true,
290
+ });
291
+ await server.listen({ port, host: TEST_HOST });
292
+ });
293
+
294
+ after(async () => {
295
+ await server.close();
296
+ await fs.remove(DATA_DIR);
297
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
298
+ else process.env.DATA_ROOT = originalDataRoot;
299
+ });
300
+
301
+ it('GET renders the form HTML', async () => {
302
+ const res = await fetch(`${baseUrl}/idp/account/delete`);
303
+ assert.strictEqual(res.status, 200);
304
+ const ct = res.headers.get('content-type') || '';
305
+ assert.match(ct, /text\/html/);
306
+ const html = await res.text();
307
+ assert.match(html, /<form\s[^>]*action="\/idp\/account\/delete"/);
308
+ assert.match(html, /name="username"/);
309
+ assert.match(html, /name="currentPassword"/);
310
+ assert.match(html, /name="confirmUsername"/);
311
+ // Form's checkbox is `keepData` (inverse of the JSON endpoint's
312
+ // purgeData) so the form's default is purge-on for the leaving-user UX.
313
+ assert.match(html, /name="keepData"/);
314
+ assert.match(html, /Delete my account permanently/);
315
+ });
316
+
317
+ it('GET sets anti-clickjacking + no-store headers', async () => {
318
+ // Destructive-action page (form for account deletion) — must not
319
+ // be cacheable or embeddable in an iframe.
320
+ const res = await fetch(`${baseUrl}/idp/account/delete`);
321
+ assert.strictEqual(res.status, 200);
322
+ assert.match(res.headers.get('cache-control') || '', /no-store/i);
323
+ assert.strictEqual(res.headers.get('x-frame-options'), 'DENY');
324
+ assert.match(res.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
325
+ });
326
+
327
+ it('POST sets the same security headers on success and error responses', async () => {
328
+ // Error response: missing fields
329
+ const errRes = await fetch(`${baseUrl}/idp/account/delete`, {
330
+ method: 'POST',
331
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
332
+ body: new URLSearchParams({ username: 'x' }),
333
+ });
334
+ assert.match(errRes.headers.get('cache-control') || '', /no-store/i);
335
+ assert.strictEqual(errRes.headers.get('x-frame-options'), 'DENY');
336
+ assert.match(errRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
337
+
338
+ // Success response: full delete flow
339
+ const id = `lyle${Date.now()}`;
340
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
341
+ const okRes = await fetch(`${baseUrl}/idp/account/delete`, {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
344
+ body: new URLSearchParams({
345
+ username: id,
346
+ currentPassword: 'password123',
347
+ confirmUsername: id,
348
+ }),
349
+ });
350
+ assert.strictEqual(okRes.status, 200);
351
+ assert.match(okRes.headers.get('cache-control') || '', /no-store/i);
352
+ assert.strictEqual(okRes.headers.get('x-frame-options'), 'DENY');
353
+ assert.match(okRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
354
+ });
355
+
356
+ it('POST happy path: deletes account AND wipes pod data by default; login fails after', async () => {
357
+ // Form's default is purge-on (the user is leaving — wipe everything).
358
+ // The JSON DELETE endpoint keeps purge-off as default (matches CLI for
359
+ // programmatic / operator use); the form diverges deliberately for the
360
+ // leaving-user UX.
361
+ const id = `harry${Date.now()}`;
362
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
363
+ const podPath = path.join(DATA_DIR, id);
364
+ assert.strictEqual(await fs.pathExists(podPath), true,
365
+ 'pod tree should exist before deletion');
366
+
367
+ const formBody = new URLSearchParams({
368
+ username: id,
369
+ currentPassword: 'password123',
370
+ confirmUsername: id,
371
+ // No keepData — defaults to purge-on
372
+ });
373
+ const res = await fetch(`${baseUrl}/idp/account/delete`, {
374
+ method: 'POST',
375
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
376
+ body: formBody,
377
+ });
378
+ assert.strictEqual(res.status, 200);
379
+ const html = await res.text();
380
+ assert.match(html, /Account deleted/);
381
+
382
+ // Login now fails
383
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
387
+ });
388
+ assert.strictEqual(reLogin.status, 401);
389
+
390
+ // Pod data also wiped (default behavior on the form)
391
+ assert.strictEqual(await fs.pathExists(podPath), false,
392
+ 'pod data should be wiped by default on the form path');
393
+ });
394
+
395
+ it('POST with keepData=on opts out of the purge, account still deleted', async () => {
396
+ const id = `iris${Date.now()}`;
397
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
398
+ const podPath = path.join(DATA_DIR, id);
399
+ assert.strictEqual(await fs.pathExists(podPath), true);
400
+
401
+ const formBody = new URLSearchParams({
402
+ username: id,
403
+ currentPassword: 'password123',
404
+ confirmUsername: id,
405
+ keepData: 'on',
406
+ });
407
+ const res = await fetch(`${baseUrl}/idp/account/delete`, {
408
+ method: 'POST',
409
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
410
+ body: formBody,
411
+ });
412
+ assert.strictEqual(res.status, 200);
413
+
414
+ // Account gone
415
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
419
+ });
420
+ assert.strictEqual(reLogin.status, 401);
421
+
422
+ // Pod data preserved (the user opted to keep it)
423
+ assert.strictEqual(await fs.pathExists(podPath), true,
424
+ 'keepData=on should preserve pod data even though account is deleted');
425
+ });
426
+
427
+ it('POST with mismatched confirmUsername renders form with error, account untouched', async () => {
428
+ const id = `jack${Date.now()}`;
429
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
430
+
431
+ const formBody = new URLSearchParams({
432
+ username: id,
433
+ currentPassword: 'password123',
434
+ confirmUsername: 'totally-different',
435
+ });
436
+ const res = await fetch(`${baseUrl}/idp/account/delete`, {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
439
+ body: formBody,
440
+ });
441
+ assert.strictEqual(res.status, 200);
442
+ const html = await res.text();
443
+ assert.match(html, /Confirmation does not match/);
444
+ // Username pre-filled in the form for retry
445
+ assert.match(html, new RegExp(`value="${id}"`));
446
+
447
+ // Account intact
448
+ const login = await fetch(`${baseUrl}/idp/credentials`, {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
452
+ });
453
+ assert.strictEqual(login.status, 200);
454
+ });
455
+
456
+ it('POST with wrong password renders form with error, account untouched', async () => {
457
+ const id = `kelly${Date.now()}`;
458
+ await createPod(baseUrl, id, `${id}@example.com`, 'password123');
459
+
460
+ const formBody = new URLSearchParams({
461
+ username: id,
462
+ currentPassword: 'wrong',
463
+ confirmUsername: id,
464
+ });
465
+ const res = await fetch(`${baseUrl}/idp/account/delete`, {
466
+ method: 'POST',
467
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
468
+ body: formBody,
469
+ });
470
+ assert.strictEqual(res.status, 200);
471
+ const html = await res.text();
472
+ assert.match(html, /incorrect/i);
473
+
474
+ // Account intact
475
+ const login = await fetch(`${baseUrl}/idp/credentials`, {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/json' },
478
+ body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
479
+ });
480
+ assert.strictEqual(login.status, 200);
481
+ });
482
+
483
+ it('POST with missing fields renders form with error', async () => {
484
+ const formBody = new URLSearchParams({
485
+ username: 'someone',
486
+ // currentPassword and confirmUsername omitted
487
+ });
488
+ const res = await fetch(`${baseUrl}/idp/account/delete`, {
489
+ method: 'POST',
490
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
491
+ body: formBody,
492
+ });
493
+ assert.strictEqual(res.status, 200);
494
+ const html = await res.text();
495
+ assert.match(html, /required/i);
496
+ });
497
+ });
498
+
499
+ describe('GET/POST /idp/account/delete — single-user mode renders disabled message', () => {
500
+ let server;
501
+ let baseUrl;
502
+ let originalDataRoot;
503
+ let originalPassword;
504
+ const DATA_DIR = './test-data-delete-form-single';
505
+
506
+ before(async () => {
507
+ originalDataRoot = process.env.DATA_ROOT;
508
+ originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
509
+ process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
510
+ await fs.remove(DATA_DIR);
511
+ await fs.ensureDir(DATA_DIR);
512
+ const port = await getAvailablePort();
513
+ baseUrl = `http://${TEST_HOST}:${port}`;
514
+ server = createServer({
515
+ logger: false,
516
+ root: DATA_DIR,
517
+ idp: true,
518
+ idpIssuer: baseUrl,
519
+ singleUser: true,
520
+ singleUserName: 'me',
521
+ singleUserPassword: 'singletest',
522
+ forceCloseConnections: true,
523
+ });
524
+ await server.listen({ port, host: TEST_HOST });
525
+ });
526
+
527
+ after(async () => {
528
+ await server.close();
529
+ await fs.remove(DATA_DIR);
530
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
531
+ else process.env.DATA_ROOT = originalDataRoot;
532
+ if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
533
+ else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
534
+ });
535
+
536
+ it('GET returns 403 with the disabled-message HTML', async () => {
537
+ // 403 keeps single-user disabled routes consistent: /idp/register,
538
+ // the JSON DELETE /idp/account, and this GET all 403 in single-user.
539
+ const res = await fetch(`${baseUrl}/idp/account/delete`);
540
+ assert.strictEqual(res.status, 403);
541
+ const html = await res.text();
542
+ assert.match(html, /single-user mode/i);
543
+ assert.match(html, /jss account delete/);
544
+ // No form
545
+ assert.doesNotMatch(html, /<form\s[^>]*action="\/idp\/account\/delete"/);
546
+ });
547
+
548
+ it('POST returns 403 with disabled-message HTML — does not delete', async () => {
549
+ const formBody = new URLSearchParams({
550
+ username: 'me',
551
+ currentPassword: 'singletest',
552
+ confirmUsername: 'me',
553
+ });
554
+ const res = await fetch(`${baseUrl}/idp/account/delete`, {
555
+ method: 'POST',
556
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
557
+ body: formBody,
558
+ });
559
+ assert.strictEqual(res.status, 403);
560
+ const html = await res.text();
561
+ assert.match(html, /single-user mode/i);
562
+
563
+ // Account still works
564
+ const login = await fetch(`${baseUrl}/idp/credentials`, {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json' },
567
+ body: JSON.stringify({ email: 'me', password: 'singletest' }),
568
+ });
569
+ assert.strictEqual(login.status, 200);
570
+ });
571
+ });
572
+
573
+ describe('DELETE /idp/account — single-user mode', () => {
574
+ let server;
575
+ let baseUrl;
576
+ let originalDataRoot;
577
+ let originalPassword;
578
+ const DATA_DIR = './test-data-delete-account-single';
579
+
580
+ before(async () => {
581
+ originalDataRoot = process.env.DATA_ROOT;
582
+ originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
583
+ process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
584
+ await fs.remove(DATA_DIR);
585
+ await fs.ensureDir(DATA_DIR);
586
+ const port = await getAvailablePort();
587
+ baseUrl = `http://${TEST_HOST}:${port}`;
588
+ server = createServer({
589
+ logger: false,
590
+ root: DATA_DIR,
591
+ idp: true,
592
+ idpIssuer: baseUrl,
593
+ singleUser: true,
594
+ singleUserName: 'me',
595
+ singleUserPassword: 'singletest',
596
+ forceCloseConnections: true,
597
+ });
598
+ await server.listen({ port, host: TEST_HOST });
599
+ });
600
+
601
+ after(async () => {
602
+ await server.close();
603
+ await fs.remove(DATA_DIR);
604
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
605
+ else process.env.DATA_ROOT = originalDataRoot;
606
+ if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
607
+ else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
608
+ });
609
+
610
+ it('returns 403 in single-user mode (deletion would brick the server)', async () => {
611
+ // Even with a valid token, the endpoint refuses in single-user mode.
612
+ // Operator must use the CLI (`jss account delete`) instead.
613
+ const token = await loginToken(baseUrl, 'me', 'singletest');
614
+
615
+ const res = await fetch(`${baseUrl}/idp/account`, {
616
+ method: 'DELETE',
617
+ headers: {
618
+ 'Content-Type': 'application/json',
619
+ 'Authorization': `Bearer ${token}`,
620
+ },
621
+ body: JSON.stringify({ currentPassword: 'singletest' }),
622
+ });
623
+ assert.strictEqual(res.status, 403);
624
+ const body = await res.json();
625
+ assert.match(body.error_description || '', /single-user/i);
626
+
627
+ // Account still functional
628
+ const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify({ email: 'me', password: 'singletest' }),
632
+ });
633
+ assert.strictEqual(reLogin.status, 200);
634
+ });
635
+ });