From 7332e55641c21e024e50f83176b302df9c488e9a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 14 Jun 2026 12:33:43 -0700 Subject: users: add authenticated self-delete endpoint Port of master 2548f757, adapted from Express to this stack's Hono handlers (c.get("user"), c.req.json(), setCookie, bail) and TigerBeetle balances (getBalance/getPending instead of the Redis balance:/pending: keys). POST /delete (auth) wipes every key tied to the calling user: user records, payments/invoices, sub-accounts, NWC apps, nostr follow graph + nip5. Gated by typing the username and refuses while balance+pending > 10k sats so funds can't be destroyed by accident. Co-Authored-By: Claude Opus 4.8 --- index.ts | 1 + routes/users.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/index.ts b/index.ts index bb944f41..83ac58e3 100644 --- a/index.ts +++ b/index.ts @@ -174,6 +174,7 @@ app.post("/register", users.create); app.post("/disable2fa", auth, users.disable2fa); app.post("/2fa", auth, users.enable2fa); app.post("/user", auth, users.update); +app.post("/delete", auth, users.deleteSelf); app.post("/reset", optional, users.reset); app.post("/upload/:type", users.upload); app.post("/acl", users.acl); diff --git a/routes/users.ts b/routes/users.ts index 05572b12..f64d5ddc 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -723,6 +723,110 @@ export default { return c.json({}); }, + // Authenticated self-delete: wipes every key tied to the calling user, + // mirroring scripts/delall-legacy.ts. Gated by typing the username (works for + // password and nostr accounts alike) and refuses while a meaningful balance + // remains so a user can't accidentally destroy funds. Balances live in + // TigerBeetle on this stack, so the guard reads getBalance/getPending, not the + // Redis balance:/pending: keys (which are deleted below only if present). + async deleteSelf(c) { + try { + const user = c.get("user"); + const { id, username, pubkey } = user; + + const body = await c.req.json().catch(() => ({})); + const confirm = (body?.confirm || "").toString().trim().toLowerCase(); + if (confirm !== username.toLowerCase()) + fail("Type your username to confirm account deletion"); + + // Allow deletion through dust (often too small to withdraw); only block a + // meaningful balance so nobody destroys real funds by accident. + const balance = (await getBalance(id)) || 0; + const pending = (await getPending(id)) || 0; + if (balance + pending > 10000) + fail("Withdraw your balance before deleting your account"); + + const keys = [ + `user:${id}`, + // username->uid mapping is stored lowercased+despaced (see register.ts); + // include both forms so a mixed-case username can't leave a dangling alias. + `user:${username.toLowerCase().replace(/\s/g, "")}`, + `user:${username}`, + `${id}:payments`, + `${id}:payments:last`, + `${id}:apps`, + `${id}:lastlen`, + `${id}:accounts`, + `${id}:contacts`, + `${id}:invoices`, + `${id}:items`, + `${id}:pins`, + `${id}:subscriptions`, + `${id}:trust`, + `${id}:square`, + `${id}:codeVerifier`, + `account:${id}`, + `balance:${id}`, + `pending:${id}`, + `credit:bitcoin:${id}`, + `credit:lightning:${id}`, + `credit:liquid:${id}`, + ]; + + if (pubkey) { + keys.push( + `user:${pubkey}`, + `${pubkey}:follows`, + `${pubkey}:follows:n`, + `${pubkey}:followers`, + `${pubkey}:followers:n`, + `${pubkey}:pubkeys`, + ); + if (user.nip5) await db.sRem("nip5", `${username}:${pubkey}`); + } + + // Connected NWC apps: drop each app record + its payment index. + // `${id}:apps` is a SET (sAdd in register.ts), so use sMembers, not lRange. + for (const ap of await db.sMembers(`${id}:apps`)) + keys.push(`app:${ap}`, `${ap}:payments`); + + // Sub-accounts and their payment records. + for (const aid of await db.lRange(`${id}:accounts`, 0, -1)) { + if (aid === id) continue; + for (const pid of await db.lRange(`${aid}:payments`, 0, -1)) + keys.push(`payment:${pid}`); + keys.push( + `account:${aid}`, + `${aid}:payments`, + `${aid}:payments:last`, + `${aid}:invoices`, + `balance:${aid}`, + `pending:${aid}`, + ); + } + + // Main-account payment + invoice records. + for (const pid of await db.lRange(`${id}:payments`, 0, -1)) + keys.push(`payment:${pid}`); + for (const iid of await db.lRange(`${id}:invoices`, 0, -1)) + keys.push(`invoice:${iid}`); + + let deleted = 0; + for (const k of keys) { + if (await db.exists(k)) { + await db.del(k); + deleted++; + } + } + + l("user self-deleted", username, id, `${deleted} keys`); + setCookie(c, "token", "", { expires: new Date(0), path: "/" }); + return c.json({ deleted: true }); + } catch (e) { + return bail(c, e.message); + } + }, + async reset(c) { const body = await c.req.json(); const { code, username, password } = body; -- cgit v1.2.3