javascript-solid-server 0.0.175 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.175",
3
+ "version": "0.0.176",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -7,9 +7,10 @@ import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
8
  import fs from 'fs-extra';
9
9
  import path from 'path';
10
- import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
10
+ import { authenticate, findByUsername, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
11
11
  import { getJwks } from './keys.js';
12
12
  import { getWebIdFromRequestAsync } from '../auth/token.js';
13
+ import { accountDeletePage } from './views.js';
13
14
 
14
15
  /**
15
16
  * Handle POST /idp/credentials
@@ -358,39 +359,65 @@ export async function handleDeleteAccount(request, reply, options = {}) {
358
359
  });
359
360
  }
360
361
 
361
- // 5. Delete the account record + indexes
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) {
362
414
  await deleteAccount(account.id);
363
415
 
364
- // 6. Optionally purge the pod's filesystem data. Mirrors the CLI
365
- // `--purge` semantics. The path is `<dataRoot>/<podName>/`.
366
- //
367
- // Use account.podName, NOT account.username: createAccount normalizes
368
- // username to lowercase (`username.toLowerCase().trim()`) but the pod
369
- // directory on disk is created with the original case (per the input
370
- // to handleCreatePod). On case-sensitive filesystems, deriving the
371
- // purge path from username would either no-op (path doesn't exist)
372
- // or hit a different directory if one exists at the lowercased name.
373
- // Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash +
374
- // underscore; no dots, no traversal sequences) so podName is safe to
375
- // join — defensive normalize stays as belt-and-suspenders.
376
- //
377
- // Best-effort: if fs.remove throws (permissions, transient FS error,
378
- // race with another consumer), the account is already deleted and we
379
- // shouldn't 500 over the leftover files. Log server-side and return
380
- // purged: false so the caller knows pod data may still exist; an
381
- // operator can finish the cleanup with a follow-up `rm -rf` or
382
- // CLI `--purge` against the now-orphaned directory.
383
416
  let purged = false;
384
417
  if (purgeData) {
385
418
  const dataRoot = process.env.DATA_ROOT || './data';
386
419
  const candidate = path.resolve(dataRoot, account.podName || account.username);
387
420
  const root = path.resolve(dataRoot);
388
- // Belt-and-suspenders: refuse to remove anything that isn't a
389
- // proper child of the data root. Won't trigger on registered pod
390
- // names; protects against config drift / future bugs. Use
391
- // path.relative so the check works when dataRoot is a filesystem
392
- // root like `/` (where startsWith(root + path.sep) would compare
393
- // against `//`, false-negative all valid children).
394
421
  const rel = path.relative(root, candidate);
395
422
  const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
396
423
  if (isProperChild) {
@@ -400,20 +427,132 @@ export async function handleDeleteAccount(request, reply, options = {}) {
400
427
  } catch (err) {
401
428
  request.log.error({ err, path: candidate, username: account.username },
402
429
  'Pod data purge failed after account deletion');
403
- // Don't surface the raw error to the user (file paths,
404
- // permission detail leak); response.purged signals the
405
- // outcome.
406
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');
407
439
  }
408
440
  }
441
+ return { purged };
442
+ }
409
443
 
410
- reply.header('Cache-Control', 'no-store');
411
- reply.header('Pragma', 'no-cache');
412
- return {
413
- ok: true,
414
- webid: account.webId,
415
- purged,
416
- };
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 }));
417
556
  }
418
557
 
419
558
  /**
package/src/idp/index.js CHANGED
@@ -24,10 +24,12 @@ import {
24
24
  handleCredentialsInfo,
25
25
  handleChangePassword,
26
26
  handleDeleteAccount,
27
+ handleAccountDeleteForm,
28
+ setNoCacheClickjackHeaders,
27
29
  } from './credentials.js';
28
30
  import * as passkey from './passkey.js';
29
31
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
30
- import { landingPage } from './views.js';
32
+ import { landingPage, accountDeletePage } from './views.js';
31
33
 
32
34
  /**
33
35
  * IdP Fastify Plugin
@@ -295,6 +297,36 @@ export async function idpPlugin(fastify, options) {
295
297
  return handleDeleteAccount(request, reply, { singleUser });
296
298
  });
297
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
+
298
330
  // Interaction routes (our custom login/consent UI)
299
331
  // These bypass oidc-provider and use our handlers
300
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
  */
@@ -269,6 +269,307 @@ describe('DELETE /idp/account — self-delete', () => {
269
269
  });
270
270
  });
271
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
+
272
573
  describe('DELETE /idp/account — single-user mode', () => {
273
574
  let server;
274
575
  let baseUrl;