CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
password-reset.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 P1 — Password reset flow. | |
| 3 | * | |
| 4 | * Public surface: | |
| 5 | * - generateResetToken() | |
| 6 | * - createPasswordResetRequest() → always { ok: true } | |
| 7 | * - consumeResetToken() | |
| 8 | * - inspectResetToken() | |
| 9 | * | |
| 10 | * Security: | |
| 11 | * - Plaintext token NEVER persists; we store only SHA-256(token). | |
| 12 | * - createPasswordResetRequest never reveals whether the email exists. | |
| 13 | * - consumeResetToken rotates the password AND drops every session. | |
| 14 | */ | |
| 15 | ||
| 16 | import { eq } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { users, sessions, passwordResetTokens } from "../db/schema"; | |
| 19 | import { hashPassword } from "./auth"; | |
| 20 | import { sendEmail, absoluteUrl, type EmailMessage } from "./email"; | |
| 21 | ||
| 22 | const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour | |
| 23 | ||
| 24 | // Test seam — swap the email sender without mock.module. | |
| 25 | type EmailSender = (msg: EmailMessage) => Promise<unknown> | unknown; | |
| 26 | let _emailSender: EmailSender = sendEmail; | |
| 27 | export function __setEmailForTests(fn: EmailSender | null): void { | |
| 28 | _emailSender = fn ?? sendEmail; | |
| 29 | } | |
| 30 | ||
| 31 | function toHex(bytes: Uint8Array): string { | |
| 32 | let out = ""; | |
| 33 | for (let i = 0; i < bytes.length; i++) out += bytes[i]!.toString(16).padStart(2, "0"); | |
| 34 | return out; | |
| 35 | } | |
| 36 | ||
| 37 | async function sha256Hex(input: string): Promise<string> { | |
| 38 | const buf = new TextEncoder().encode(input); | |
| 39 | const digest = await crypto.subtle.digest("SHA-256", buf); | |
| 40 | return toHex(new Uint8Array(digest)); | |
| 41 | } | |
| 42 | ||
| 43 | export function generateResetToken(): { plaintext: string; hash: string } { | |
| 44 | const bytes = crypto.getRandomValues(new Uint8Array(32)); | |
| 45 | const plaintext = toHex(bytes); | |
| 46 | const hasher = new Bun.CryptoHasher("sha256"); | |
| 47 | hasher.update(plaintext); | |
| 48 | const hash = hasher.digest("hex"); | |
| 49 | return { plaintext, hash }; | |
| 50 | } | |
| 51 | ||
| 52 | function escapeHtml(s: string): string { | |
| 53 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); | |
| 54 | } | |
| 55 | ||
| 56 | function buildResetEmail(opts: { username: string; resetUrl: string }) { | |
| 57 | const subject = "Reset your Gluecron password"; | |
| 58 | const text = [ | |
| 59 | `Hi ${opts.username},`, | |
| 60 | "", | |
| 61 | "We received a request to reset the password for your Gluecron account.", | |
| 62 | "", | |
| 63 | `Reset your password: ${opts.resetUrl}`, | |
| 64 | "", | |
| 65 | "This link expires in 1 hour. If you didn't request a reset, ignore", | |
| 66 | "this email — your password won't change.", | |
| 67 | "", | |
| 68 | "— gluecron", | |
| 69 | ].join("\n"); | |
| 70 | ||
| 71 | const safeUser = escapeHtml(opts.username); | |
| 72 | const safeUrl = escapeHtml(opts.resetUrl); | |
| 73 | const html = `<!doctype html> | |
| 74 | <html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title></head> | |
| 75 | <body style="margin:0;padding:0;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#c9d1d9"> | |
| 76 | <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117"> | |
| 77 | <tr><td align="center" style="padding:32px 16px"> | |
| 78 | <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden"> | |
| 79 | <tr><td style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);padding:24px 28px"> | |
| 80 | <div style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-0.01em">gluecron</div> | |
| 81 | <div style="font-size:13px;color:rgba(255,255,255,0.85);margin-top:2px">Password reset</div> | |
| 82 | </td></tr> | |
| 83 | <tr><td style="padding:28px"> | |
| 84 | <p style="margin:0 0 12px;font-size:15px;color:#e6edf3">Hi ${safeUser},</p> | |
| 85 | <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9">We received a request to reset the password for your Gluecron account.</p> | |
| 86 | <p style="margin:0 0 24px"><a href="${safeUrl}" style="display:inline-block;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9999px">Reset password</a></p> | |
| 87 | <p style="margin:0 0 8px;font-size:13px;color:#8b949e">Or copy this link into your browser:</p> | |
| 88 | <p style="margin:0 0 24px;font-size:12px;color:#8b949e;word-break:break-all"><a href="${safeUrl}" style="color:#58a6ff;text-decoration:none">${safeUrl}</a></p> | |
| 89 | <p style="margin:0;font-size:12px;color:#8b949e;line-height:1.55">This link expires in 1 hour. If you didn't request a reset, ignore this email — your password won't change.</p> | |
| 90 | </td></tr> | |
| 91 | <tr><td style="padding:16px 28px;border-top:1px solid #30363d;font-size:11px;color:#6e7681">gluecron — AI-native code intelligence</td></tr> | |
| 92 | </table> | |
| 93 | </td></tr> | |
| 94 | </table> | |
| 95 | </body></html>`; | |
| 96 | ||
| 97 | return { subject, text, html }; | |
| 98 | } | |
| 99 | ||
| 100 | export function buildResetUrl(plaintextToken: string): string { | |
| 101 | const path = `/reset-password?token=${encodeURIComponent(plaintextToken)}&utm_source=password_reset`; | |
| 102 | return absoluteUrl(path); | |
| 103 | } | |
| 104 | ||
| 105 | export async function createPasswordResetRequest( | |
| 106 | email: string, | |
| 107 | opts: { requestIp?: string } = {} | |
| 108 | ): Promise<{ ok: boolean }> { | |
| 109 | const normalized = String(email || "").trim().toLowerCase(); | |
| 110 | if (!normalized || !normalized.includes("@")) return { ok: true }; | |
| 111 | ||
| 112 | try { | |
| 113 | const [user] = await db | |
| 114 | .select({ id: users.id, username: users.username, email: users.email }) | |
| 115 | .from(users) | |
| 116 | .where(eq(users.email, normalized)) | |
| 117 | .limit(1); | |
| 118 | ||
| 119 | if (!user) { | |
| 120 | console.error(`[password-reset] no user for email=${JSON.stringify(normalized)} ip=${opts.requestIp || "?"} — generic success returned`); | |
| 121 | return { ok: true }; | |
| 122 | } | |
| 123 | ||
| 124 | const { plaintext, hash } = generateResetToken(); | |
| 125 | const expiresAt = new Date(Date.now() + RESET_TTL_MS); | |
| 126 | ||
| 127 | await db.insert(passwordResetTokens).values({ | |
| 128 | userId: user.id, | |
| 129 | tokenHash: hash, | |
| 130 | expiresAt, | |
| 131 | requestIp: opts.requestIp || null, | |
| 132 | }); | |
| 133 | ||
| 134 | const resetUrl = buildResetUrl(plaintext); | |
| 135 | const msg = buildResetEmail({ username: user.username, resetUrl }); | |
| 136 | ||
| 137 | // Fire-and-forget — don't block the response on email send. | |
| 138 | Promise.resolve() | |
| 139 | .then(() => _emailSender({ to: user.email, subject: msg.subject, text: msg.text, html: msg.html })) | |
| 140 | .catch((err) => console.error("[password-reset] email send error:", err)); | |
| 141 | ||
| 142 | return { ok: true }; | |
| 143 | } catch (err) { | |
| 144 | console.error("[password-reset] createPasswordResetRequest error:", err); | |
| 145 | return { ok: true }; | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | export async function consumeResetToken( | |
| 150 | token: string, | |
| 151 | newPassword: string | |
| 152 | ): Promise<{ ok: boolean; reason?: string }> { | |
| 153 | const plaintext = String(token || "").trim(); | |
| 154 | if (!plaintext) return { ok: false, reason: "invalid" }; | |
| 155 | if (!newPassword || newPassword.length < 8) return { ok: false, reason: "weak" }; | |
| 156 | ||
| 157 | try { | |
| 158 | const hash = await sha256Hex(plaintext); | |
| 159 | const [row] = await db | |
| 160 | .select() | |
| 161 | .from(passwordResetTokens) | |
| 162 | .where(eq(passwordResetTokens.tokenHash, hash)) | |
| 163 | .limit(1); | |
| 164 | ||
| 165 | if (!row) return { ok: false, reason: "invalid" }; | |
| 166 | if (row.usedAt) return { ok: false, reason: "used" }; | |
| 167 | if (new Date(row.expiresAt).getTime() < Date.now()) return { ok: false, reason: "expired" }; | |
| 168 | ||
| 169 | const passwordHash = await hashPassword(newPassword); | |
| 170 | ||
| 171 | await db.update(users).set({ passwordHash, updatedAt: new Date() }).where(eq(users.id, row.userId)); | |
| 172 | await db.update(passwordResetTokens).set({ usedAt: new Date() }).where(eq(passwordResetTokens.id, row.id)); | |
| 173 | await db.delete(sessions).where(eq(sessions.userId, row.userId)); | |
| 174 | ||
| 175 | return { ok: true }; | |
| 176 | } catch (err) { | |
| 177 | console.error("[password-reset] consumeResetToken error:", err); | |
| 178 | return { ok: false, reason: "invalid" }; | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | export async function inspectResetToken(token: string): Promise<{ valid: boolean; reason?: string }> { | |
| 183 | const plaintext = String(token || "").trim(); | |
| 184 | if (!plaintext) return { valid: false, reason: "invalid" }; | |
| 185 | try { | |
| 186 | const hash = await sha256Hex(plaintext); | |
| 187 | const [row] = await db | |
| 188 | .select({ id: passwordResetTokens.id, expiresAt: passwordResetTokens.expiresAt, usedAt: passwordResetTokens.usedAt }) | |
| 189 | .from(passwordResetTokens) | |
| 190 | .where(eq(passwordResetTokens.tokenHash, hash)) | |
| 191 | .limit(1); | |
| 192 | if (!row) return { valid: false, reason: "invalid" }; | |
| 193 | if (row.usedAt) return { valid: false, reason: "used" }; | |
| 194 | if (new Date(row.expiresAt).getTime() < Date.now()) return { valid: false, reason: "expired" }; | |
| 195 | return { valid: true }; | |
| 196 | } catch (err) { | |
| 197 | console.error("[password-reset] inspectResetToken error:", err); | |
| 198 | return { valid: false, reason: "invalid" }; | |
| 199 | } | |
| 200 | } |