idp: self-service DELETE /idp/account endpoint (#352)#391
Conversation
Authenticated owner can now delete their own account via HTTP. Mirrors the password-rotation pattern from #351 (PUT /idp/credentials): caller authenticates with their existing session, re-proves possession by sending currentPassword in the body, server resolves the account from the caller's WebID and deletes it. Owner-only by construction — the cross-account attack (A authenticated, sends B's password) returns 401 and touches nothing. Two new shape elements vs. password rotation: 1. Single-user mode rejects with 403. Deleting the single account would brick the IdP until re-seed; the operator path stays the CLI (`jss account delete`). Test asserts both 403 status and account-still-functional after. 2. Optional `purgeData: true` body flag mirrors the CLI's `--purge`. Removes the pod's filesystem tree at <dataRoot>/<username>/. Path is normalized + verified to be a proper child of dataRoot (belt-and-suspenders against future config drift; usernames are already validated by registration). Default is false — account record gone, pod data preserved, matches the CLI's default. Endpoint surface: DELETE /idp/account Headers: Authorization (existing session / DPoP) Body: { currentPassword: string, purgeData?: boolean } Returns: 200 { ok, webid, purged } — success 400 — missing/invalid currentPassword 401 — unauthenticated, or wrong password 403 — single-user mode, or no account for caller's WebID Rate-limited 5/min/IP. UI deferred — the issue allows it; auth for a /idp HTML page is its own concern (existing /idp landing is unauthenticated). Endpoint + tests is the unit of value here. Doctor-app or pane work in a follow-up. Tests cover: unauthenticated, missing field, wrong password, happy path (subsequent login fails), purgeData=true wipes filesystem, purgeData=false preserves it, cross-account safety, single-user 403. 8 new tests; 627/627 full suite passes.
There was a problem hiding this comment.
Pull request overview
Adds a self-service IdP endpoint for authenticated users to delete their own account (with optional pod data purge), aligned with the existing “re-enter currentPassword” pattern used for password rotation.
Changes:
- Register
DELETE /idp/accountwith per-IP rate limiting. - Implement
handleDeleteAccount()to authenticate by WebID, verifycurrentPassword, delete the account record, and optionally purge<DATA_ROOT>/<username>/. - Add a dedicated test suite covering success and key failure modes (unauthenticated, missing password, wrong password, cross-account attempt, purge behavior, single-user mode rejection).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/idp/index.js |
Registers the new DELETE /idp/account route with rate limiting and passes singleUser mode into the handler. |
src/idp/credentials.js |
Adds handleDeleteAccount() implementing owner-only deletion and optional filesystem purge. |
test/idp-delete-account.test.js |
Adds integration tests for the new endpoint, including purge/no-purge and single-user mode behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * 403 — single-user mode (deletion would brick the server until | ||
| * re-seed; operator should use the CLI), or no account for the | ||
| * caller's WebID | ||
| * 404 — no account at all (already deleted) |
There was a problem hiding this comment.
Fixed in 471f759: JSDoc no longer mentions 404. The 'no account for caller's WebID' case correctly lands at 403 (the caller has a valid token, they're identifying themselves — just not for an account this server holds). Comment updated with the rationale.
| // 5. Delete the account record + indexes | ||
| await deleteAccount(account.id); | ||
|
|
||
| // 6. Optionally purge the pod's filesystem data. Mirrors the CLI | ||
| // `--purge` semantics. The path is <dataRoot>/<username>/, with | ||
| // username already validated at registration (#321 alphanum/dash/dot | ||
| // rules) so no traversal risk in practice; defensive normalize | ||
| // anyway. | ||
| let purgedPath = null; | ||
| if (purgeData) { | ||
| const dataRoot = process.env.DATA_ROOT || './data'; | ||
| const candidate = path.resolve(dataRoot, account.username); | ||
| const root = path.resolve(dataRoot); | ||
| // Belt-and-suspenders: refuse to remove anything that isn't a | ||
| // proper child of the data root. Won't trigger on registered | ||
| // usernames; protects against config drift / future bugs. | ||
| if (candidate.startsWith(root + path.sep) && candidate !== root) { | ||
| await fs.remove(candidate); | ||
| purgedPath = candidate; | ||
| } |
There was a problem hiding this comment.
Real concern. Fixed in 471f759: purge is now best-effort. The account is deleted before the purge attempt, so a throw was previously surfacing as a 500 over a partially-completed state. Now wrapped in try/catch — on throw the operator log gets the full error via request.log.error (err / path / username) and the response carries `purged: false` so the caller knows pod data may still exist. Raw err message not surfaced to the user (file paths / permission details are sensitive). Operator can finish cleanup with the CLI `--purge` against the orphaned directory.
Two fixes per the review:
1. JSDoc no longer claims a 404 failure mode the code doesn't return.
"No account for caller's WebID" lands at 403, not 404 — the caller
had a valid token (so they're identifying themselves), just not for
an account this server holds. Added the rationale to the comment.
2. Purge is now best-effort. fs.remove() can throw (permissions, FS
races, etc.); the account is deleted *before* the purge, so a
throw would have surfaced as a 500 over a partially-completed
deletion (account gone, pod data lingering). New shape:
- try/catch around fs.remove
- on throw: log server-side via request.log.error (with err / path
/ username for the operator log) and continue with purged: false
- response.purged accurately signals outcome; an operator can
finish the cleanup with the CLI `--purge` against the orphaned
directory if needed
- raw err message NOT surfaced to the user — file paths and
permission detail are sensitive
8/8 delete-account tests still pass.
| const dataRoot = process.env.DATA_ROOT || './data'; | ||
| const candidate = path.resolve(dataRoot, account.username); | ||
| const root = path.resolve(dataRoot); | ||
| // Belt-and-suspenders: refuse to remove anything that isn't a |
There was a problem hiding this comment.
Real bug, fixed in 82589e7. createAccount() lowercases the username (`username.toLowerCase().trim()`) but `account.podName` is stored as-is. On case-sensitive FS the pod dir is at `/Alice/` while `account.username` is `alice` — derived purge path didn't match. Fixed by using `account.podName` (with username fallback for older records). Same bug fixed at bin/jss.js:776 since the CLI's `--purge` had the identical issue (only hidden on case-insensitive devboxes like macOS default).
| // username already validated at registration (#321 alphanum/dash/dot | ||
| // rules) so no traversal risk in practice; defensive normalize |
There was a problem hiding this comment.
Fixed in 82589e7. The comment was describing the slug regex (which allows dots), not the pod-name regex `/^[a-zA-Z0-9_-]+$/` at handlers/container.js:273 — alphanum + dash + underscore, no dots, allows uppercase. Comment now matches reality.
| assert.strictEqual(await fs.pathExists(podPath), false, | ||
| 'pod data should be purged'); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Fixed in 82589e7: new test "purgeData: true removes the on-disk pod dir even when it has uppercase letters" creates a pod with mixed-case name `Greta${ts}`, verifies the on-disk dir at the mixed-case path is gone after purge. Would fail on the previous code path on a case-sensitive filesystem. 9/9 delete-account tests now pass.
Real bug. createAccount() in src/idp/accounts.js normalizes username
to lowercase (`username.toLowerCase().trim()`), but the pod directory
on disk is created using the original-case input to handleCreatePod.
On case-sensitive filesystems these can differ:
Account record: { username: "alice", podName: "Alice" }
On disk: <dataRoot>/Alice/
The previous purge logic derived its path from account.username, so
it would either no-op (path didn't exist) or hit a different
directory if one happened to exist at the lowercased name. Both
outcomes are wrong: pod data lingers, or the wrong tree is removed.
Fix: use account.podName, falling back to account.username only if
podName is somehow absent (older account records or edge cases).
Same fix applied to the CLI at bin/jss.js:776 — same code path,
same bug, would only have been hidden on case-insensitive devboxes
(macOS default).
Test: new "purgeData: true removes the on-disk pod dir even when it
has uppercase letters" — creates a pod with mixed-case name "Greta…",
verifies the on-disk dir at the mixed-case path is gone after purge.
This would fail on the previous code path on a case-sensitive FS.
Also corrected the inline comment about pod-name validation: it was
describing the slug regex (allows dots), not the pod-name regex
(/^[a-zA-Z0-9_-]+$/, no dots, allows underscores and uppercase).
9/9 delete-account tests pass.
| * Owner-initiated account deletion. Authenticated caller proves | ||
| * possession via re-entering currentPassword (matches the | ||
| * password-rotation pattern in #351). Optional `purgeData: true` also | ||
| * removes the pod's filesystem tree at <dataRoot>/<username>/. |
There was a problem hiding this comment.
Fixed in 2e059c9: JSDoc now says `//` to match what the code actually purges (account.podName, falling back to username only if podName is absent).
| // Belt-and-suspenders: refuse to remove anything that isn't a | ||
| // proper child of the data root. Won't trigger on registered | ||
| // pod names; protects against config drift / future bugs. | ||
| if (candidate.startsWith(root + path.sep) && candidate !== root) { |
There was a problem hiding this comment.
Real edge case. Fixed in 2e059c9 by switching from `candidate.startsWith(root + path.sep)` to `path.relative(root, candidate)`. The startsWith check would fail-closed when DATA_ROOT is a filesystem root like `/` (root is 1 char, root + path.sep is 2 chars, no valid child path matches). path.relative handles that correctly and tightens the `..` traversal check up-front in the same expression.
Two fixes: 1. JSDoc no longer says `<dataRoot>/<username>/` — corrected to `<podName>` to match the implementation (which now uses account.podName, falling back to username only if podName is absent). 2. The "candidate is a proper child of dataRoot" guard switched from `candidate.startsWith(root + path.sep)` to `path.relative(root, candidate)`. The startsWith check failed at filesystem roots: if DATA_ROOT is `/`, then `root` is `/` (1 char) and `root + path.sep` is `//` (2 chars), so any legitimate child like `/alice` fails the prefix check and the purge silently no-ops. path.relative handles that correctly — and rejects `..` traversal up front, which is tighter anyway. 9/9 delete-account tests still pass.
| * Owner-initiated account deletion. Authenticated caller proves | ||
| * possession via re-entering currentPassword (matches the | ||
| * password-rotation pattern in #351). Optional `purgeData: true` also | ||
| * removes the pod's filesystem tree at <dataRoot>/<username>/. |
There was a problem hiding this comment.
Fixed in 88a40cb. There were two JSDoc lines mentioning the path; I corrected the inline comment in pass 3 but missed the function-level summary at the top of handleDeleteAccount. Now both say <podName> (falling back to <username> if podName is absent).
…ame> I caught the inline comment in pass 3 but missed this higher up in the function-level JSDoc. Same correction: <username> → <podName> with fallback note.
Three small fixes: 1. handleAccountDeleteForm JSDoc clarifies it's POST-only; GET is a separate route in index.js that just renders the form via accountDeletePage. Pass-1 wording was 'GET / POST' which implied both routes flow through this handler. 2. accountDeletePage's JSDoc still mentioned 'identifier (username or email)' from before the email simplification in pass 2. Now says 'username + password' to match the actual UI. 3. The checkbox copy promised 'Removes <dataRoot>/<username>/' but the purge path is derived from account.podName (which can differ from the lowercased username on case-sensitive FS, per #391 pass 2). Softened to 'Removes the pod folder on disk' — accurate without leaking the path shape. 17/17 tests pass.
* idp: HTML form for self-service account deletion (#392) Human-friendly UI on top of the JSON DELETE /idp/account endpoint (#391). Public unauthenticated page at /idp/account/delete; auth happens at submission time via username + password. Matches the existing /idp landing and /idp/register pattern — no JavaScript required, accessibility-first form-encoded POST. ## Form fields - Username or email - Current password (re-entry as proof of possession) - Type your username again to confirm (destructive-action UX guard) - Optional checkbox: also delete pod data ## Routes added - GET /idp/account/delete → render the form (or single-user disabled message) - POST /idp/account/delete → process submission Same 5/min/IP rate-limit as the JSON endpoint. Single-user mode shows the disabled-message page instead of the form on both GET and POST, matching the JSON endpoint's 403 policy. Operator path remains the CLI (`jss account delete`). ## Internal refactor Factored the shared "delete + optional purge" logic into `deleteAccountAndOptionallyPurge(request, account, purgeData)` so the JSON endpoint and form endpoint share the same path. Pod-data purge uses `account.podName` (per #391 pass 2) and path.relative (per #391 pass 3) so the case-sensitivity fix and FS-root edge case carry over. ## Tests added (8) - GET renders form HTML with all required fields - POST happy path → success page + login fails after - POST with purgeData=on wipes pod tree - POST with mismatched confirmUsername → form re-rendered with error + username pre-filled for retry, account untouched - POST with wrong password → form re-rendered with error, account untouched - POST with missing fields → form re-rendered with error - Single-user mode: GET renders disabled message (no form) - Single-user mode: POST also renders disabled message — does NOT delete (verified: account still functional after the POST) 636/636 full suite passes. * idp: address Copilot review on #393 — finish refactor + honest copy Six fixes per the review: 1. handleDeleteAccount now actually uses the new deleteAccountAndOptionallyPurge helper. The helper was added in the prior commit but the original function still had its own inline purge block — divergence risk. Now both endpoints share the same path verbatim. 2. JSDoc reference \`handleAccountDeletePost\` → \`handleAccountDeleteForm\` (the actual exported name). Two route methods (GET + POST) live in the same handler; doc updated to reflect that. 3. JSDoc claimed failures "redirect back to the GET form with an error message" — the implementation actually re-renders the form in-place with status 200. Doc corrected to match (single response, no redirect). 4. Form handler now respects the \`purged\` return from the helper. When the user requested purgeData but the purge didn't run (fs threw, path-relative check rejected), the success page surfaces a "your account was deleted but the pod-data purge did not complete" notice. Account deletion succeeded — that part can't be undone — but the operator may need to finish cleanup. 5. Warning copy on the form softened: was "any active sessions will be removed", which the server doesn't actually implement. Now: "future sign-ins with this username will fail" + an italic note that already-issued access tokens may remain usable until their expiry. 6. Same correction on the success-page copy. 17/17 delete-account tests still pass. * idp: Copilot review pass 2 on #393 — JSDoc accuracy, drop email-or-username UX confusion Four polish fixes: 1. handleAccountDeleteForm reference in deleteAccountAndOptionallyPurge's JSDoc was still mis-named handleAccountDeletePost (one stray spot the pass-1 fix missed). 2. accountDeletePage's JSDoc updated: "redirect-back-with-error" was wrong (we re-render in place); also documented the new purgeFailed param added in pass 1. 3. + 4. Form label simplified from "Username or email" → "Username". Confirms a user point: JSS doesn't really do email — no SMTP, no verification flow, no recovery flow, the HTML register form doesn't ask for email at all. The "email" field in accounts.js is a data-layer artifact (auto-generates <username>@jss if absent) and isn't surfaced anywhere user-facing. Saying "username or email" on this form mismatched reality and confused the destructive-action confirm field ("Type your username again to confirm"). Now both fields consistently say username, matching what users will actually enter. Server-side authenticate() still tries findByUsername then findByEmail for back-compat with anyone who registered programmatically with a real email; that fallback is just unreachable from this form since the field never accepts email anymore. Cleaning that up across the wider auth surface is a separate PR. 17/17 delete-account tests still pass. * idp: pass 3 on #393 — JSDoc / copy accuracy Three small fixes: 1. handleAccountDeleteForm JSDoc clarifies it's POST-only; GET is a separate route in index.js that just renders the form via accountDeletePage. Pass-1 wording was 'GET / POST' which implied both routes flow through this handler. 2. accountDeletePage's JSDoc still mentioned 'identifier (username or email)' from before the email simplification in pass 2. Now says 'username + password' to match the actual UI. 3. The checkbox copy promised 'Removes <dataRoot>/<username>/' but the purge path is derived from account.podName (which can differ from the lowercased username on case-sensitive FS, per #391 pass 2). Softened to 'Removes the pod folder on disk' — accurate without leaking the path shape. 17/17 tests pass. * idp: pass 4 on #393 — fix body-parsing comment to match reality The comment claimed @fastify/formbody puts fields directly on request.body, but no such plugin is installed. JSS's wildcard parseAs:'buffer' content parser at server.js:190 means request.body arrives as a Buffer; the URLSearchParams parse below is what's actually load-bearing. Comment updated to describe what the code genuinely does. * idp: pass 5 on #393 — side-effect-free verify, honest copy, 403 single-user, flip purge default to ON Five fixes (4 from Copilot, 1 from maintainer call to flip the default): 1. handleAccountDeleteForm now uses findByUsername + verifyPassword instead of authenticate(). authenticate() writes lastLogin as a side effect, which is wrong shape for a destructive proof-of- possession check (and would fail the deletion if the account file weren't writable). Mirrors handleChangePassword and the JSON handleDeleteAccount. 2. Warning copy corrected. Previous text said "any active sessions will be removed" + "WebID URI will become a tombstone" — the server does neither. Now: "Future sign-ins with this username will fail" + "external links to your WebID URI will become dangling" (since pod data is being wiped, the URI no longer resolves). 3. Single-user mode now returns 403 from both GET and POST routes (matched HTML body kept). Consistency: /idp/register's disabled-route policy is 403, the JSON DELETE endpoint is 403, and now the form path is too. 4. Body-parsing comment corrected: was claiming @fastify/formbody is registered (it isn't); JSS uses parseAs:'buffer' for `*` so body arrives as a Buffer and the URLSearchParams parse is the load-bearing logic. 5. Form's purge default flipped from off → ON. The checkbox is now `keepData` (inverse of the JSON endpoint's `purgeData`), default unchecked = purge-on. A user filling out the form is leaving the server; wiping their pod data with the account is the expected shape, and "Keep my pod data on this server" is the explicit opt-out. The JSON DELETE endpoint keeps `purgeData: false` as the default (matches the CLI's --purge being opt-in for operator scripts; programmatic callers shouldn't accidentally wipe data). Form and JSON endpoint diverge deliberately because the use cases differ. Tests: - happy path now expects pod data wiped by default + login fails - new test for keepData=on (opts out of purge; account still deleted, pod data preserved) - single-user GET / POST now expect 403 - form-renders test now checks for name="keepData" 17/17 delete-account tests pass. * idp: pass 7 on #393 — conditional warning copy, log path-safety rejection, fix confirm comment Three fixes: 1. Warning copy now describes the conditional behavior accurately: 'By default your pod data is also wiped — check the box below if you want to keep it.' Previous wording said pod data 'will be removed' unconditionally, which becomes wrong if the user checks the keep-data opt-out. 2. Path-safety rejection (when isProperChild is false) now logs a warn with path/dataRoot/podName context so operators can diagnose any 'purge did not complete' message that wasn't a fs.remove throw. No path leaks to the client; signal stays in server logs. 3. Comment on the destructive-action UX guard was misleading. It claimed the case-sensitive compare prevents typos hitting a different (lowercased) record — but findByUsername lowercases internally, so 'Alice' + 'Alice' both resolve to the 'alice' record. The compare is purely the typing-it-twice confirmation pattern (typo catch / accidental-submit guard); doesn't gate which record gets deleted. Comment now reflects what the code actually does. 17/17 tests pass. * idp: pass 8 on #393 — anti-clickjacking + no-store headers on every delete-form response Real security findings. The delete-account form takes a current-password re-entry and the success/error pages echo state — neither should be cacheable or embeddable in an iframe. Both threats (cache replay, clickjacking) are real on a destructive endpoint. Added a small exported helper: setNoCacheClickjackHeaders(reply): Cache-Control: no-store Pragma: no-cache X-Frame-Options: DENY Content-Security-Policy: frame-ancestors 'none' Wired into: - GET /idp/account/delete (form + 403 disabled-message variant) - POST handler entry point — covers every return path (success, error re-render, single-user 403, missing-field, mismatched confirm, wrong password) Tests added (2): - GET response carries all four headers - POST response carries them on both error (missing fields) and success (full delete) paths 19/19 delete-account tests pass.
Closes #352.
Summary
DELETE /idp/accountlets an authenticated owner delete their own account.PUT /idp/credentials(Add HTTP endpoint for end users to change their own password #351): caller's existing token + body re-confirmscurrentPassword. Account is resolved from the caller's WebID, so cross-account attacks (A authenticated, sends B's password) return 401 and don't touch anything.purgeData: trueremoves the pod's filesystem tree at<dataRoot>/<username>/, mirroring the CLI's--purgesemantics. Default is false (matches CLI default).jss account delete) stays the operator path.Endpoint
Rate-limited 5/min/IP.
Out of scope (deferred to follow-ups)
/idp/account/delete— the issue allows it; auth for an /idp HTML page is its own concern (existing /idp landing is unauthenticated). Endpoint + tests is the unit of value here. Doctor-app or pane work in a follow-up.findByWebId()will appear valid until they expire. Same shape as the password-rotation endpoint; tracked elsewhere if needed.Files
src/idp/credentials.js—handleDeleteAccount(request, reply, { singleUser }), ~110 LOCsrc/idp/index.js— route registration, ~14 LOCtest/idp-delete-account.test.js— 8 new tests, ~300 LOCTest plan
npm testpasses (currently 627/627 locally)curl -X DELETE … -d '{"currentPassword":"..."}'→ 200, subsequent login → 401"purgeData": true→ pod tree goneRefs
jss account delete(data layer reused)