Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

account-deletion.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

account-deletion.tsBlame304 lines · 1 contributor
c63b860Claude1/**
2 * Block P5 — Account deletion with a 30-day grace period.
3 *
4 * The privacy policy (src/routes/legal/privacy.tsx §5) promises:
5 * "Account data: retained while your account is active and for thirty
6 * (30) days after account deletion, after which we intend to purge it."
7 *
8 * Flow:
9 * 1. User clicks "Delete my account" in /settings.
10 * → `scheduleAccountDeletion()` marks `users.deleted_at = now()`,
11 * `users.deletion_scheduled_for = now() + 30 days`, deletes all
12 * sessions, audits `account.deletion.scheduled`, sends a
13 * confirmation email.
14 * 2. During the grace period the user can sign back in. The /login
15 * handler calls `cancelAccountDeletion()` which clears both columns,
16 * audits, and sends a "welcome back" email.
17 * 3. The autopilot `account-purge` task hard-deletes any rows whose
18 * `deletion_scheduled_for` is in the past. Capped at 50 users per
19 * tick. Per-user errors are logged + skipped — never thrown — so a
20 * single FK violation can't stall the queue.
21 *
7293c67Claude22 * Cascade executed by `purgeScheduledAccounts` for each user:
23 * - Cancel any active Stripe subscription (if STRIPE_SECRET_KEY is set).
24 * - Delete bare git repo directories from disk (GIT_REPOS_PATH/<username>/).
25 * - Hard-delete the `users` row; FK ON DELETE CASCADE handles:
26 * sessions, ssh_keys, api_tokens, notifications, stars, reactions,
27 * ai_chats, push_subscriptions, oauth_access_tokens, gists, etc.
28 * - audit_log.user_id is already set to ON DELETE SET NULL at the DB level,
29 * so audit rows are automatically anonymised when the user row is removed.
30 *
c63b860Claude31 * Nothing here throws. All DB / email failures are logged and swallowed.
32 */
33
34import { eq, lt } from "drizzle-orm";
7293c67Claude35import { rm } from "fs/promises";
36import { join } from "path";
c63b860Claude37import { db } from "../db";
7293c67Claude38import { repositories, sessions, userQuotas, users } from "../db/schema";
c63b860Claude39import { sendEmail, absoluteUrl } from "./email";
40import { audit } from "./notify";
41
42/** Grace period before a scheduled deletion becomes a hard purge. */
43export const GRACE_PERIOD_DAYS = 30;
44/** Default per-tick cap for `purgeScheduledAccounts`. */
45export const DEFAULT_PURGE_CAP = 50;
46
47const MS_PER_DAY = 24 * 60 * 60 * 1000;
48
49export async function scheduleAccountDeletion(
50 userId: string,
51 opts: { now?: Date } = {}
52): Promise<{ ok: boolean; scheduledFor: Date }> {
53 const now = opts.now ?? new Date();
54 const scheduledFor = new Date(now.getTime() + GRACE_PERIOD_DAYS * MS_PER_DAY);
55
56 let user: { id: string; username: string; email: string } | null = null;
57 try {
58 const rows = await db
59 .update(users)
60 .set({
61 deletedAt: now,
62 deletionScheduledFor: scheduledFor,
63 updatedAt: now,
64 })
65 .where(eq(users.id, userId))
66 .returning({
67 id: users.id,
68 username: users.username,
69 email: users.email,
70 });
71 user = rows[0] ?? null;
72 } catch (err) {
73 console.error("[account-deletion] schedule update failed:", err);
74 return { ok: false, scheduledFor };
75 }
76
77 if (!user) return { ok: false, scheduledFor };
78
79 try {
80 await db.delete(sessions).where(eq(sessions.userId, userId));
81 } catch (err) {
82 console.error("[account-deletion] session purge failed:", err);
83 }
84
85 await audit({
86 userId,
87 action: "account.deletion.scheduled",
88 targetType: "user",
89 targetId: userId,
90 metadata: { scheduledFor: scheduledFor.toISOString() },
91 });
92
93 const tpl = renderScheduledEmail({ username: user.username, scheduledFor });
94 await sendEmail({ to: user.email, subject: tpl.subject, text: tpl.text });
95
96 return { ok: true, scheduledFor };
97}
98
99export async function cancelAccountDeletion(
100 userId: string
101): Promise<{ ok: boolean }> {
102 let user: { id: string; username: string; email: string } | null = null;
103 try {
104 const rows = await db
105 .update(users)
106 .set({
107 deletedAt: null,
108 deletionScheduledFor: null,
109 updatedAt: new Date(),
110 })
111 .where(eq(users.id, userId))
112 .returning({
113 id: users.id,
114 username: users.username,
115 email: users.email,
116 });
117 user = rows[0] ?? null;
118 } catch (err) {
119 console.error("[account-deletion] cancel update failed:", err);
120 return { ok: false };
121 }
122
123 if (!user) return { ok: false };
124
125 await audit({
126 userId,
127 action: "account.deletion.cancelled",
128 targetType: "user",
129 targetId: userId,
130 });
131
132 const tpl = renderRestoredEmail({ username: user.username });
133 await sendEmail({ to: user.email, subject: tpl.subject, text: tpl.text });
134
135 return { ok: true };
136}
137
7293c67Claude138/**
139 * Cancel a Stripe subscription at period end for GDPR purge.
140 * Silently swallows all errors — a Stripe outage must never block deletion.
141 */
142async function cancelStripeSubscription(subscriptionId: string): Promise<void> {
143 const key = process.env.STRIPE_SECRET_KEY;
144 if (!key || key.length < 10) return;
145 try {
146 await fetch(`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, {
147 method: "DELETE",
148 headers: {
149 Authorization: `Bearer ${key}`,
150 "Content-Type": "application/x-www-form-urlencoded",
151 },
152 });
153 } catch (err) {
154 console.error(`[account-deletion] stripe cancel failed for sub=${subscriptionId}:`, err);
155 }
156}
157
c63b860Claude158export async function purgeScheduledAccounts(
159 opts: { now?: Date; cap?: number } = {}
160): Promise<{ purged: number; errors: number }> {
161 const now = opts.now ?? new Date();
162 const cap = Math.max(1, opts.cap ?? DEFAULT_PURGE_CAP);
163
164 let candidates: Array<{ id: string; username: string; email: string }> = [];
165 try {
166 candidates = await db
167 .select({
168 id: users.id,
169 username: users.username,
170 email: users.email,
171 })
172 .from(users)
173 .where(lt(users.deletionScheduledFor, now))
174 .limit(cap);
175 } catch (err) {
176 console.error("[account-deletion] purge candidate query failed:", err);
177 return { purged: 0, errors: 1 };
178 }
179
180 let purged = 0;
181 let errors = 0;
182 for (const c of candidates) {
183 try {
7293c67Claude184 // 1. Cancel any active Stripe subscription before removing the user row.
185 try {
186 const quotaRows = await db
187 .select({ stripeSubscriptionId: userQuotas.stripeSubscriptionId })
188 .from(userQuotas)
189 .where(eq(userQuotas.userId, c.id))
190 .limit(1);
191 const subId = quotaRows[0]?.stripeSubscriptionId;
192 if (subId) {
193 await cancelStripeSubscription(subId);
194 }
195 } catch (err) {
196 console.error(`[account-deletion] quota lookup failed for user=${c.id}:`, err);
197 }
198
199 // 2. Delete bare git repo directories from disk (GIT_REPOS_PATH/<username>/).
200 // We collect all repo diskPaths owned by this user and remove each one.
201 try {
202 const repoRows = await db
203 .select({ diskPath: repositories.diskPath })
204 .from(repositories)
205 .where(eq(repositories.ownerId, c.id));
206 for (const r of repoRows) {
207 const absPath = r.diskPath.startsWith("/")
208 ? r.diskPath
209 : join(process.env.GIT_REPOS_PATH || "./repos", r.diskPath);
210 await rm(absPath, { recursive: true, force: true });
211 }
212 // Also remove the per-user directory if it exists (may be empty or already gone).
213 const userDir = join(process.env.GIT_REPOS_PATH || "./repos", c.username);
214 await rm(userDir, { recursive: true, force: true });
215 } catch (err) {
216 console.error(`[account-deletion] disk cleanup failed for user=${c.id}:`, err);
217 }
218
219 // 3. Hard-delete the users row.
220 // FK ON DELETE CASCADE handles: sessions, ssh_keys, api_tokens,
221 // notifications, stars, reactions, ai_chats, push_subscriptions,
222 // oauth_access_tokens, gists, user_quotas, user_totp, user_passkeys, etc.
223 // FK ON DELETE SET NULL handles: audit_log.user_id (anonymises entries).
c63b860Claude224 const deleted = await db
225 .delete(users)
226 .where(eq(users.id, c.id))
227 .returning({ id: users.id });
228 if (deleted.length > 0) {
229 purged += 1;
230 await audit({
231 userId: null,
232 action: "account.purged",
233 targetType: "user",
234 targetId: c.id,
235 metadata: { username: c.username },
236 });
237 }
238 } catch (err) {
239 errors += 1;
240 console.error(
241 `[account-deletion] purge failed for user=${c.id} (${c.username}):`,
242 err
243 );
244 }
245 }
246
247 return { purged, errors };
248}
249
250export function daysUntilPurge(
251 user: { deletionScheduledFor: Date | null },
252 now: Date = new Date()
253): number | null {
254 if (!user.deletionScheduledFor) return null;
255 const ms = user.deletionScheduledFor.getTime() - now.getTime();
256 if (ms <= 0) return 0;
257 return Math.ceil(ms / MS_PER_DAY);
258}
259
260export function renderScheduledEmail(input: {
261 username: string;
262 scheduledFor: Date;
263}): { subject: string; text: string } {
264 const when = input.scheduledFor.toUTCString();
265 const subject = "Your Gluecron account is scheduled for deletion";
266 const text = [
267 `Hi ${input.username},`,
268 "",
269 `Your Gluecron account will be permanently deleted on ${when}.`,
270 "",
271 "All of your repos, issues, PRs, and settings will be purged after that",
272 "date. If you change your mind, just sign in any time before then and we",
273 "will cancel the deletion automatically.",
274 "",
275 `Cancel deletion: ${absoluteUrl("/login")}`,
276 "",
277 "— gluecron",
278 ].join("\n");
279 return { subject, text };
280}
281
282export function renderRestoredEmail(input: { username: string }): {
283 subject: string;
284 text: string;
285} {
286 const subject = "Welcome back — your Gluecron account has been restored";
287 const text = [
288 `${input.username},`,
289 "",
290 "Your account is no longer scheduled for deletion. Everything's right",
291 "where you left it.",
292 "",
293 `Visit your dashboard: ${absoluteUrl("/dashboard")}`,
294 "",
295 "— gluecron",
296 ].join("\n");
297 return { subject, text };
298}
299
300export const __test = {
301 GRACE_PERIOD_DAYS,
302 DEFAULT_PURGE_CAP,
303 MS_PER_DAY,
304};