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.
| c63b860 | 1 | /** |
| 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 | * | |
| 7293c67 | 22 | * 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 | * | |
| c63b860 | 31 | * Nothing here throws. All DB / email failures are logged and swallowed. |
| 32 | */ | |
| 33 | ||
| 34 | import { eq, lt } from "drizzle-orm"; | |
| 7293c67 | 35 | import { rm } from "fs/promises"; |
| 36 | import { join } from "path"; | |
| c63b860 | 37 | import { db } from "../db"; |
| 7293c67 | 38 | import { repositories, sessions, userQuotas, users } from "../db/schema"; |
| c63b860 | 39 | import { sendEmail, absoluteUrl } from "./email"; |
| 40 | import { audit } from "./notify"; | |
| 41 | ||
| 42 | /** Grace period before a scheduled deletion becomes a hard purge. */ | |
| 43 | export const GRACE_PERIOD_DAYS = 30; | |
| 44 | /** Default per-tick cap for `purgeScheduledAccounts`. */ | |
| 45 | export const DEFAULT_PURGE_CAP = 50; | |
| 46 | ||
| 47 | const MS_PER_DAY = 24 * 60 * 60 * 1000; | |
| 48 | ||
| 49 | export 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 | ||
| 99 | export 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 | ||
| 7293c67 | 138 | /** |
| 139 | * Cancel a Stripe subscription at period end for GDPR purge. | |
| 140 | * Silently swallows all errors — a Stripe outage must never block deletion. | |
| 141 | */ | |
| 142 | async 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 | ||
| c63b860 | 158 | export 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 { | |
| 7293c67 | 184 | // 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). | |
| c63b860 | 224 | 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 | ||
| 250 | export 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 | ||
| 260 | export 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 | ||
| 282 | export 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 | ||
| 300 | export const __test = { | |
| 301 | GRACE_PERIOD_DAYS, | |
| 302 | DEFAULT_PURGE_CAP, | |
| 303 | MS_PER_DAY, | |
| 304 | }; |