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

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.

password-reset.tsBlame200 lines · 1 contributor
c63b860Claude1/**
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
16import { eq } from "drizzle-orm";
17import { db } from "../db";
18import { users, sessions, passwordResetTokens } from "../db/schema";
19import { hashPassword } from "./auth";
20import { sendEmail, absoluteUrl, type EmailMessage } from "./email";
21
22const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
23
24// Test seam — swap the email sender without mock.module.
25type EmailSender = (msg: EmailMessage) => Promise<unknown> | unknown;
26let _emailSender: EmailSender = sendEmail;
27export function __setEmailForTests(fn: EmailSender | null): void {
28 _emailSender = fn ?? sendEmail;
29}
30
31function 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
37async 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
43export 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
52function escapeHtml(s: string): string {
53 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
54}
55
56function 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
100export function buildResetUrl(plaintextToken: string): string {
101 const path = `/reset-password?token=${encodeURIComponent(plaintextToken)}&utm_source=password_reset`;
102 return absoluteUrl(path);
103}
104
105export 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
149export 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
182export 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}