aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-14 12:33:43 -0700
committerAdam Soltys <[email protected]>2026-06-17 07:47:46 -0700
commit7332e55641c21e024e50f83176b302df9c488e9a (patch)
tree447bbc95d201bbb14d1bf4cdbb313f4aad9fd4c6
parent85849ede6fb6bd9151703ebb8cf1b2b2df385b99 (diff)
downloadcoinos-server-7332e55641c21e024e50f83176b302df9c488e9a.tar.gz
coinos-server-7332e55641c21e024e50f83176b302df9c488e9a.zip
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 <[email protected]>
-rw-r--r--index.ts1
-rw-r--r--routes/users.ts104
2 files changed, 105 insertions, 0 deletions
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;