aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-13 14:21:49 -0700
committerAdam Soltys <[email protected]>2026-06-13 14:21:49 -0700
commit2548f7578ab879bc7eb292462b13f41e7f3112e5 (patch)
treea75ff4a96e3749017db7a4d65761c42e75c23b11 /routes
parentca28c19fe26d3f81ae62d2d3f0dc8b175849a9a8 (diff)
downloadcoinos-server-2548f7578ab879bc7eb292462b13f41e7f3112e5.tar.gz
coinos-server-2548f7578ab879bc7eb292462b13f41e7f3112e5.zip
users: add authenticated self-delete endpoint
POST /delete (auth) wipes every key tied to the calling user, mirroring scripts/delall-legacy.ts: 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 (1M context) <[email protected]>
Diffstat (limited to 'routes')
-rw-r--r--routes/users.ts103
1 files changed, 103 insertions, 0 deletions
diff --git a/routes/users.ts b/routes/users.ts
index 66d1504c..3bf41355 100644
--- a/routes/users.ts
+++ b/routes/users.ts
@@ -646,6 +646,109 @@ export default {
res.send({});
},
+ // 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 balance remains so a
+ // user can't accidentally destroy funds.
+ async deleteSelf(req, res) {
+ try {
+ const { user } = req;
+ const { id, username, pubkey } = user;
+
+ const confirm = (req.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 = Number(await db.get(`balance:${id}`)) || 0;
+ const pending = Number(await db.get(`pending:${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`);
+ try {
+ res.clearCookie("token");
+ } catch {}
+ res.send({ deleted: true });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async reset(req, res) {
const {
body: { code, username, password },