aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/deluser.ts154
-rw-r--r--routes/users.ts90
2 files changed, 165 insertions, 79 deletions
diff --git a/lib/deluser.ts b/lib/deluser.ts
new file mode 100644
index 00000000..55d05386
--- /dev/null
+++ b/lib/deluser.ts
@@ -0,0 +1,154 @@
+import { archive, db, g } from "$lib/db";
+import ln from "$lib/ln";
+
+// Delete an account and everything hanging off it — the same sweep as
+// scripts/delall.ts on the prod host. Beyond the plain keys this also:
+// - disables the account's bolt12 offers on the node, so a payer (e.g. an
+// OCEAN payout) fails instead of settling into the house wallet;
+// - removes the invoice pointer keys (invoice:<bolt11|bolt12|offer_id>) so a
+// late settlement resolves to "no invoice" and is skipped;
+// - deletes the same keys from the archive, which gf()/getUser fall back
+// to, so nothing resurrects from there.
+// Without those, a deleted user's offer kept receiving and wedged the lightning
+// listener for two hours on 2026-09-05 (pay_index 1068006).
+//
+// Offers are disabled before any key is deleted, so if the node can't be
+// reached this throws with the account still intact.
+export default async function deleteUser(user) {
+ const { id, username, pubkey } = user;
+
+ // Fail up front rather than halfway through the sweep.
+ await archive.ping();
+
+ const ga = async (k: string) => {
+ const v = await archive.get(k);
+ try {
+ return JSON.parse(v);
+ } catch {
+ return v;
+ }
+ };
+ const getInv = async (iid: string) =>
+ (await g(`invoice:${iid}`)) ?? (await ga(`invoice:${iid}`));
+
+ const aids = [
+ id,
+ ...(await db.lRange(`${id}:accounts`, 0, -1)).filter((a) => a !== id),
+ ];
+ const keys = new Set<string>();
+ const add = (...ks: (string | false | undefined | null)[]) =>
+ ks.forEach((k) => k && keys.add(k));
+
+ // username->uid mapping is stored lowercased+despaced (see register.ts);
+ // include both forms so a mixed-case username can't leave a dangling alias.
+ add(
+ `user:${id}`,
+ `user:${username}`,
+ `user:${username.toLowerCase().replace(/\s/g, "")}`,
+ );
+ add(
+ ...[
+ "payments",
+ "payments:last",
+ "apps",
+ "lastlen",
+ "accounts",
+ "contacts",
+ "invoices",
+ "items",
+ "pins",
+ "subscriptions",
+ "trust",
+ "square",
+ "codeVerifier",
+ ].map((s) => `${id}:${s}`),
+ );
+ add(
+ `account:${id}`,
+ `balance:${id}`,
+ `pending:${id}`,
+ `credit:bitcoin:${id}`,
+ `credit:lightning:${id}`,
+ `credit:liquid:${id}`,
+ );
+ if (pubkey)
+ add(
+ `user:${pubkey}`,
+ `${pubkey}:follows`,
+ `${pubkey}:follows:n`,
+ `${pubkey}:followers`,
+ `${pubkey}:followers:n`,
+ `${pubkey}:pubkeys`,
+ );
+
+ // Connected NWC apps. `${id}:apps` is a SET (sAdd in register.ts).
+ for (const ap of await db.sMembers(`${id}:apps`))
+ add(`app:${ap}`, `${ap}:payments`);
+
+ // Invoices and payments for the main account and every sub-account.
+ const invoiceIds = new Set<string>();
+ const bolt12s = new Set<string>();
+ for (const aid of aids) {
+ if (aid !== id)
+ add(
+ `account:${aid}`,
+ `${aid}:payments`,
+ `${aid}:payments:last`,
+ `${aid}:invoices`,
+ `balance:${aid}`,
+ `pending:${aid}`,
+ );
+ for (const pid of await db.lRange(`${aid}:payments`, 0, -1))
+ add(`payment:${pid}`);
+ for (const iid of await db.lRange(`${aid}:invoices`, 0, -1)) {
+ invoiceIds.add(iid);
+ add(`invoice:${iid}`);
+ const inv = await getInv(iid);
+ if (!inv) continue;
+ add(inv.hash && `invoice:${inv.hash}`, inv.text && `invoice:${inv.text}`);
+ if (inv.type === "bolt12" && inv.hash) bolt12s.add(inv.hash);
+ }
+ }
+
+ // bolt12 offers: match by bolt12 string, or through the invoice:<offer_id>
+ // pointer (which also catches offers whose :invoices entry is already gone).
+ const { offers } = await ln.call("listoffers", {});
+ let disabled = 0;
+ for (const o of offers) {
+ let mine = bolt12s.has(o.bolt12);
+ if (!mine) {
+ let iid = await g(`invoice:${o.offer_id}`);
+ if (iid && typeof iid === "object") iid = iid.id;
+ if (iid) {
+ if (invoiceIds.has(iid)) mine = true;
+ else {
+ const inv = await getInv(iid);
+ if (inv && aids.includes(inv.uid)) {
+ mine = true;
+ add(`invoice:${iid}`, inv.hash && `invoice:${inv.hash}`);
+ }
+ }
+ }
+ }
+ if (!mine) continue;
+ add(`invoice:${o.offer_id}`);
+ if (o.active) {
+ await ln.call("disableoffer", { offer_id: o.offer_id });
+ disabled++;
+ }
+ }
+
+ if (pubkey && user.nip5) await db.sRem("nip5", `${username}:${pubkey}`);
+
+ let deleted = 0;
+ for (const k of keys) {
+ const inDb = await db.exists(k);
+ const inArc = await archive.exists(k);
+ if (!inDb && !inArc) continue;
+ deleted++;
+ if (inDb) await db.del(k);
+ if (inArc) await archive.del(k);
+ }
+
+ return { deleted, disabled };
+}
diff --git a/routes/users.ts b/routes/users.ts
index 9f730b6f..400b554b 100644
--- a/routes/users.ts
+++ b/routes/users.ts
@@ -9,6 +9,7 @@ import { isDefaultName } from "$lib/default-names";
import { upgradeLegacyNsec } from "$lib/legacynsec";
import { mail, templates } from "$lib/mail";
import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr";
+import deleteUser from "$lib/deluser";
import register from "$lib/register";
import { assertNameFree } from "$lib/names";
import { isReserved } from "$lib/reserved";
@@ -880,22 +881,24 @@ 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.
+ // Authenticated self-delete: wipes every key tied to the calling user via
+ // lib/deluser (the same sweep as scripts/delall.ts, offers and archive
+ // included). Gated by typing the username (works for password and nostr
+ // accounts alike) plus the PIN when one is set, 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 { id, username } = user;
const confirm = (req.body?.confirm || "").toString().trim().toLowerCase();
if (confirm !== username.toLowerCase())
fail("Type your username to confirm account deletion");
+ await requirePin(req);
// Allow deletion through dust (often too small to withdraw); only block
// a meaningful balance so nobody destroys real funds by accident. Sum
- // EVERY account (main + sub-accounts) — the deletion loop below wipes each
+ // EVERY account (main + sub-accounts) — the sweep wipes each
// sub-account's balance:/pending: keys unconditionally, so a guard that
// only checked the main account let a user with funds parked in a
// sub-account pass and silently destroy them.
@@ -909,80 +912,9 @@ export default {
if (total > 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++;
- }
- }
+ const { deleted, disabled } = await deleteUser(user);
- l("user self-deleted", username, id, `${deleted} keys`);
+ l("user self-deleted", username, id, `${deleted} keys, ${disabled} offers`);
try {
res.clearCookie("token");
} catch {}