import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import { PASSWORD_HASH_ROUNDS } from '../passwordHashing'; import crypto from 'node:crypto'; import { pool, requireRow } from '../db'; import { requireCustomer } from '../middleware/customerAuth'; import { sendMail } from '../mailer'; import { renderTemplate, greeting, formatDuration } from '../emailTemplates'; import { getSettings } from '../adminSettings'; import { loadStoredTemplate } from './adminEmailTemplates'; import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT, isValidEmail } from '../utils'; import { ItemStatus } from '../types'; import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts'; import { asyncRoute } from '../asyncRoute'; import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit'; // Shared with passkey sign-in, so both paths establish a session identically // rather than in two places that merely agree today (#39). import { setSessionCookie, createSession } from '../customerSession'; const router = Router(); // Registration, changing an address, and resending all need the same three // steps: supersede any outstanding link, mint a new one, send it. Written out // three times they would drift, and the step most likely to be forgotten is the // first — which is the one that matters, since an un-superseded link means an // older message in the inbox still verifies. // // Sending is fire-and-forget by the same rule the rest of this file follows: // the token row is written first, so a send that fails cannot leave a customer // believing a link exists that does not, only waiting for one that never came. async function issueVerificationEmail( customerId: number, email: string, firstName: string | null, lastName: string | null = null ): Promise { await pool.query( `DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`, [customerId] ); const { verifyTokenHours, greetingFormat, greetingFallback } = await getSettings(); const token = crypto.randomBytes(24).toString('hex'); await pool.query( `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`, [token, customerId, new Date(Date.now() + verifyTokenHours * 60 * 60 * 1000)] ); const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`; const template = renderTemplate('verification', await loadStoredTemplate('verification'), { greeting: greeting(firstName, greetingFormat, greetingFallback, lastName), firstName: firstName ?? '', lastName: lastName ?? '', verifyUrl, expiresIn: formatDuration(verifyTokenHours) }); sendMail(email, template.subject, template.html) .catch(err => console.error('verify email send failed', err)); } // setSessionCookie and createSession now live in ../customerSession, shared with // passkey sign-in. #39 requires that path to establish a session identically to // this one, and sharing the code is what makes that true rather than intended. // The subset of a customers row that is safe to return to the customer it // belongs to. Typed as its own shape rather than `any` so that adding a column // to the table — a password hash, a token, an internal note — cannot silently // start being echoed back by a `...c` somewhere downstream. interface CustomerRow { id: number; email: string; // Nullable despite registration requiring both, because customers who // registered while the field was optional genuinely have no name. The // requirement is enforced at registration, not asserted by the schema. first_name: string | null; last_name: string | null; email_verified: boolean; marketing_consent: boolean; favorite_alerts: boolean; created_at: Date; } /** * A whole `customers` row, as `SELECT *` returns it. * * Extends CustomerRow rather than restating it, so the relationship is the one * that actually holds: everything safe to return is also on the record, and the * fields below are the ones that are not. Adding a column to the table means * adding it here and deciding, at that moment, whether it belongs in * CustomerRow too — which is the decision the comment above is about. * * Kept in step with the schema by hand; nothing checks this against Postgres. */ interface CustomerRecord extends CustomerRow { password_hash: string; disabled_at: Date | null; unsubscribe_token: string; marketing_consent_at: Date | null; marketing_consent_text: string | null; // A separate purpose from marketing, so a separate column, timestamp and // stored wording rather than a second meaning layered onto the pair above. // False for every customer the migration touched: none of them was asked. analytics_consent: boolean; analytics_consent_at: Date | null; analytics_consent_text: string | null; favorite_alerts_at: Date | null; favorite_alerts_text: string | null; } /** Rows that are only ever probed for existence. */ interface IdRow { id: number; } /** * A single-use link. `kind` distinguishes verification from password reset; * both are read the same way and both are deleted once spent. */ interface CustomerTokenRow { token: string; customer_id: number; kind: string; expires_at: Date; created_at: Date; } /** Just the flag the disabled check reads. */ interface DisabledAtRow { disabled_at: Date | null; } /** A favorited item as the account page lists it. */ interface FavoriteRow { item_id: number; created_at: Date; name: string; status: ItemStatus; } /** * A whole `orders` row, as the data export returns it. * * Worth reading before changing the export: `raw_event` is the processor's * entire capture payload, and this route sends every column of this row to the * customer verbatim. That is defensible for a GDPR export — it is their * transaction — but it is a decision rather than an accident, and typing it is * what makes it visible. The order-history route above deliberately selects six * named columns instead. */ interface OrderRecord { id: number; item_id: number | null; customer_id: number | null; checkout_id: number | null; processor: string; processor_order_id: string | null; amount_cents: number | null; status: string | null; raw_event: unknown; created_at: Date; } /** One line of a customer's own order history. */ interface CustomerOrderRow { id: number; processor: string; amount_cents: number; status: string; created_at: Date; item_name: string; } /** * Whether this customer has agreed to the *current* analytics wording, which is * the only thing that authorises the Brevo tracker (#56). * * Reads the analytics columns and nothing else. It must never consult * `marketing_consent`: those are two purposes with two recipients, and GDPR * requires consent to be granular — a customer who wants the emails and not the * tracking has to be able to have exactly that. Quebec's Law 25 s.8.1 is * stricter again and requires this to be off until the customer switches it on, * which is why the column defaults to false. * * Comparing the stored string is the point rather than an implementation * detail. The flag says a customer agreed to something; the text says what. If * the sentence is ever re-worded, everyone who agreed to the previous one stops * qualifying and is asked again, rather than being silently carried into a * broader agreement they never saw. * * Computed here rather than stored, so it can never drift from the constant. * * Exported for the unit test, and narrowed to the two fields it actually reads * rather than taking a whole CustomerRecord — the rule is about those two and * nothing else, and a test should not have to invent a customer to state it. */ export function analyticsConsent( c: Pick ): boolean { return c.analytics_consent && c.analytics_consent_text === ANALYTICS_CONSENT_TEXT; } /** * Takes a CustomerRecord rather than a CustomerRow because `analytics_consent` * is derived from `marketing_consent_text`, which is not on the narrower type. * Every caller already holds a full record — each query is `SELECT *`. */ function publicCustomer(c: CustomerRecord) { return { id: c.id, email: c.email, first_name: c.first_name, last_name: c.last_name, email_verified: c.email_verified, marketing_consent: c.marketing_consent, // Its own purpose, its own answer. A customer can have either, both, or // neither, and the UI has to be able to show that honestly. analytics_consent: analyticsConsent(c), favorite_alerts: c.favorite_alerts, created_at: c.created_at }; } router.post('/register', asyncRoute(async (req: Request, res: Response) => { const { email, password, firstName, lastName, marketingConsent, analyticsConsent: analyticsConsentGiven } = req.body; if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) { return res.status(400).json({ error: 'valid email and password (min 8 chars) required' }); } // Named individually rather than as one "name is required", so a form that // filled one field and not the other is told which. const first = String(firstName ?? '').trim(); const last = String(lastName ?? '').trim(); if (!first) { return res.status(400).json({ error: 'first name is required' }); } if (!last) { return res.status(400).json({ error: 'last name is required' }); } const normalizedEmail = String(email).toLowerCase().trim(); const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]); if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' }); const passwordHash = await bcrypt.hash(password, PASSWORD_HASH_ROUNDS); const unsubscribeToken = crypto.randomBytes(16).toString('hex'); const consent = !!marketingConsent; // Read independently of marketingConsent, and absent means false. A client // that sends neither, or only the marketing one, registers a customer who is // not tracked — which is the right answer for a request that never carried an // analytics answer at all. const analytics = !!analyticsConsentGiven; const { rows } = await pool.query( `INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, analytics_consent, analytics_consent_at, analytics_consent_text, unsubscribe_token) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ normalizedEmail, passwordHash, first, last, consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null, analytics, analytics ? new Date() : null, analytics ? ANALYTICS_CONSENT_TEXT : null, unsubscribeToken ] ); const customer = requireRow(rows, 'the registration INSERT'); await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name); const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); })); router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => { const { token } = req.body; const { rows } = await pool.query( `SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`, [token] ); const [verifyToken] = rows; if (!verifyToken) return res.status(400).json({ error: 'invalid or expired token' }); await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [verifyToken.customer_id]); await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]); res.json({ status: 'verified' }); })); // The limiter is mounted after requireCustomer, deliberately: it keys on // req.customerId, which does not exist until requireCustomer has run. Mounted // the other way round every anonymous caller would share one bucket. router.post( '/resend-verification', requireCustomer, verificationResendLimiter, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); // requireCustomer has already matched this id against a live session. const customer = requireRow(rows, 'the signed-in customer'); // Refused rather than quietly sending. A pointless email is worse than an // answer, and the account page has no reason to offer the button here. if (customer.email_verified) { return res.status(400).json({ error: 'your email address is already verified' }); } await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name); res.status(204).end(); }) ); // Always answers 200, whether or not the address has an account. A response // that differed would let anyone test addresses for membership. // // Note /register still reveals existence via its 409 on a duplicate address, // so this protection is currently partial — closing that is its own change. router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => { const email = String(req.body?.email || '').toLowerCase().trim(); if (!email || !isValidEmail(email)) { return res.status(400).json({ error: 'a valid email is required' }); } const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]); const customer = rows[0]; if (customer && !customer.disabled_at) { // Supersede any outstanding token, so a link cannot be resurrected later // from an older message in the customer's inbox. await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]); const { passwordResetHours, greetingFormat, greetingFallback } = await getSettings(); const token = crypto.randomBytes(32).toString('hex'); await pool.query( `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`, [token, customer.id, new Date(Date.now() + passwordResetHours * 60 * 60 * 1000)] ); const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`; const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), { greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name), firstName: customer.first_name ?? '', lastName: customer.last_name ?? '', resetUrl, expiresIn: formatDuration(passwordResetHours) }); sendMail(customer.email, resetTemplate.subject, resetTemplate.html) .catch(err => console.error('password reset email send failed', err)); } res.json({ status: 'sent' }); })); // Deliberately not rate limited. The limiter above is keyed on the submitted // email, which this endpoint does not carry, so reusing it would put every // customer completing a reset into one shared bucket. Nor is a limit needed // here: the token is 32 random bytes, single-use, and expires in an hour, and // the expensive bcrypt hash only runs *after* the token has been matched, so // invalid guesses cost a single indexed lookup. router.post('/reset-password', asyncRoute(async (req: Request, res: Response) => { const { token, password } = req.body || {}; if (!password || String(password).length < 8) { // Checked before the token is looked at, so a rejected attempt does not // consume the customer's only reset link. return res.status(400).json({ error: 'password must be at least 8 characters' }); } const { rows } = await pool.query( `SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`, [token] ); const [resetToken] = rows; if (!resetToken) return res.status(400).json({ error: 'invalid or expired token' }); const customerId = resetToken.customer_id; // A token issued before the account was disabled would otherwise still mint a // fresh session. const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]); if (owner[0]?.disabled_at) { return res.status(403).json({ error: 'this account has been disabled' }); } const passwordHash = await bcrypt.hash(String(password), PASSWORD_HASH_ROUNDS); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( // The customer has demonstrably received mail at this address, which is // exactly what verification proves, so an unverified address becomes // verified here. `UPDATE customers SET password_hash = $1, email_verified = true WHERE id = $2`, [passwordHash, customerId] ); // Every existing session goes, including any an attacker holds. Without // this, a reset prompted by a compromise leaves the intruder signed in for // up to 30 days. await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]); await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]); const sessionToken = await createSession(customerId); setSessionCookie(res, sessionToken); res.json(publicCustomer(requireRow(fresh, 'the customer whose password was just reset'))); })); router.post('/login', asyncRoute(async (req: Request, res: Response) => { const { email, password } = req.body; const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]); const customer = rows[0]; if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) { return res.status(401).json({ error: 'invalid email or password' }); } // Only after the password checks out, so a wrong password still looks like a // wrong password and this does not become a bulk membership oracle. if (customer.disabled_at) { return res.status(403).json({ error: 'this account has been disabled' }); } const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); })); router.post('/logout', asyncRoute(async (req: Request, res: Response) => { const token = req.cookies?.rd_session; if (token) await pool.query(`DELETE FROM customer_sessions WHERE token = $1`, [token]); res.clearCookie('rd_session'); res.status(204).end(); })); router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT f.item_id, f.created_at, i.name, i.status FROM favorites f JOIN items i ON i.id = f.item_id WHERE f.customer_id = $1 ORDER BY f.created_at DESC`, [req.customerId] ); res.json(rows); })); router.post('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows: item } = await pool.query(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]); if (!item.length) return res.status(404).json({ error: 'not found' }); // Idempotent: a double click, or two tabs, must not be an error. await pool.query( `INSERT INTO favorites (customer_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [req.customerId, req.params.itemId] ); res.status(201).json({ item_id: Number(req.params.itemId) }); })); router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => { await pool.query(`DELETE FROM favorites WHERE customer_id = $1 AND item_id = $2`, [req.customerId, req.params.itemId]); res.status(204).end(); })); // A consent of its own, deliberately not the marketing flag. Recorded the same // way as the marketing consent — flag, timestamp, and the exact wording shown — // so the record says what was actually agreed to. router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const enabled = !!req.body?.enabled; const { rows } = await pool.query( `UPDATE customers SET favorite_alerts = $1, favorite_alerts_at = $2, favorite_alerts_text = $3 WHERE id = $4 RETURNING *`, [enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId] ); res.json(publicCustomer(requireRow(rows, 'the favorite-alerts UPDATE'))); })); router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const [customer] = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]).then(r => r.rows); if (!customer) return res.status(404).json({ error: 'not found' }); res.json(publicCustomer(customer)); })); // Kept in step with registration for consistency. Note nothing in the frontend // calls this today — the account page has no name editing — so this is API // surface without a caller rather than a path in use. router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { firstName, lastName } = req.body; // Registration demands both and refuses each by name. Accepting empty values // here would let a customer clear fields they could not have skipped when // signing up, which is the same rule disagreeing with itself. const first = String(firstName ?? '').trim(); const last = String(lastName ?? '').trim(); if (!first) { return res.status(400).json({ error: 'first name is required' }); } if (!last) { return res.status(400).json({ error: 'last name is required' }); } const { rows } = await pool.query( `UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`, [first, last, req.customerId] ); res.json(publicCustomer(requireRow(rows, 'the name UPDATE'))); })); router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { currentPassword, newPassword } = req.body; if (!newPassword || String(newPassword).length < 8) { return res.status(400).json({ error: 'new password must be at least 8 characters' }); } const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const customer = requireRow(rows, 'the signed-in customer'); if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) { return res.status(401).json({ error: 'current password is incorrect' }); } const newHash = await bcrypt.hash(newPassword, PASSWORD_HASH_ROUNDS); await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]); // Password reset already ends every session, on the reasoning that a password // is changed precisely when the old one may be known to someone else. A // change left the other sessions alive, which is the same reasoning reaching // the opposite conclusion for no recorded reason. The current session is // spared so the change does not eject the person making it. await pool.query( `DELETE FROM customer_sessions WHERE customer_id = $1 AND token <> $2`, [req.customerId, req.cookies?.rd_session ?? ''] ); res.status(204).end(); })); // Changing the address a password reset goes to is how an account is taken // over, so this asks for the current password exactly as change-password does. // A live session alone is not enough. router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { currentPassword, email } = req.body; const normalized = String(email ?? '').toLowerCase().trim(); if (!normalized || !isValidEmail(normalized)) { return res.status(400).json({ error: 'a valid email is required' }); } const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const customer = requireRow(rows, 'the signed-in customer'); if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) { return res.status(401).json({ error: 'current password is incorrect' }); } if (normalized === customer.email) { return res.status(400).json({ error: 'that is already your email address' }); } const { rows: taken } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalized]); if (taken.length) { return res.status(409).json({ error: 'an account with this email already exists' }); } // Captured before the update, because it is where the notice has to go. const previousEmail = customer.email; await pool.query( `UPDATE customers SET email = $1, email_verified = false WHERE id = $2`, [normalized, req.customerId] ); // Supersedes any outstanding link as part of issuing the new one, so a // message already sitting in the old inbox cannot verify the new address. // // Both sends happen after the row is written, never before — the same rule // favoriteAlerts follows, so a change that failed cannot produce mail saying // it succeeded. await issueVerificationEmail(req.customerId as number, normalized, customer.first_name, customer.last_name); const { greetingFormat, greetingFallback } = await getSettings(); const notice = renderTemplate('emailChanged', await loadStoredTemplate('emailChanged'), { greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name), firstName: customer.first_name ?? '', lastName: customer.last_name ?? '', newEmail: normalized }); sendMail(previousEmail, notice.subject, notice.html) .catch(err => console.error('email change notice send failed', err)); const { rows: updated } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); res.json(publicCustomer(requireRow(updated, 'the customer after the email change'))); })); router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const consent = !!req.body.marketingConsent; await pool.query( `UPDATE customers SET marketing_consent = $1, marketing_consent_at = now(), marketing_consent_text = $2 WHERE id = $3`, [consent, consent ? MARKETING_CONSENT_TEXT : 'Withdrew consent via account settings', req.customerId] ); res.status(204).end(); })); /** * Analytics consent, on its own route rather than as a second field on * `/me/consent` (#56). * * Separate because the two are separate purposes and must be separately * refusable. One endpoint taking both would make it possible for a single call * to change an answer the customer did not touch — which is the bundling * problem again, moved from the form into the API. * * Withdrawal writes the reason rather than the consent sentence, so the stored * text never claims agreement to something that was declined. Same convention * as marketing consent above. */ router.post('/me/analytics-consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const consent = !!req.body.analyticsConsent; await pool.query( `UPDATE customers SET analytics_consent = $1, analytics_consent_at = now(), analytics_consent_text = $2 WHERE id = $3`, [consent, consent ? ANALYTICS_CONSENT_TEXT : 'Withdrew analytics consent via account settings', req.customerId] ); res.status(204).end(); })); router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name FROM orders o JOIN items i ON i.id = o.item_id WHERE o.customer_id = $1 ORDER BY o.created_at DESC`, [req.customerId] ); res.json(rows); })); router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]); res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"'); res.json({ customer: publicCustomer(requireRow(customerRows, 'the signed-in customer')), orders: orderRows, exported_at: new Date().toISOString() }); })); router.delete('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => { await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]); await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]); res.clearCookie('rd_session'); res.status(204).end(); })); export default router;