Files
redefined-designs/backend/src/routes/customers.ts
T
synAdminandClaude Opus 5 f95013850b
Linting / lint (pull_request) Canceled after 0s
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
feat(passkeys): authentication ceremony (#39)
A customer signs in with a registered passkey. Usernameless: they are never asked who they are, the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials so this would work.

That choice does more than improve the experience. This issue requires that failures not reveal whether an email has an account or has passkeys registered, and with no email ever sent to the endpoint there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever.

Session creation is shared rather than reimplemented, which is the requirement stated most sharply here: a second, subtly different session path is how auth bugs get in. setSessionCookie and createSession move to customerSession.ts and both paths import them. Two implementations that agree today are two that can be changed one at a time, and the one that would be forgotten is whichever is not the password path, because that is the one every manual test exercises. Social sign-in will use the same module when #332 lands.

The signature counter policy #37 deferred is decided here, and both halves matter. Requiring an increase from every authenticator refuses synced passkeys, which report zero forever by design and are what most customers actually use. Requiring it from none discards the only signal that a hardware credential has been cloned. So zero against zero is accepted and anything else must strictly increase — and the asymmetry is deliberate, because an authenticator that has ever reported a real counter is held to the strict rule from then on and cannot downgrade itself to zero to escape it.

A disabled account is refused, read from the same row as the credential rather than a second query that could disagree. Enforcing that only on the password path would have left passkeys as a way around it.

Every refusal answers the same way. No such credential, a disabled account, a bad assertion and a stalled counter are all that did not work to the caller; saying which would turn the endpoint into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is logged, because the customer cannot act on it and the person who can is reading the logs.

The challenge is spent by deleting it, with the expiry in the same statement, so a replay finds nothing to delete and a stale challenge fails the same way. It is passed to the library as a predicate rather than a value, which is what makes a usernameless flow possible at all — the challenge is not known until the assertion names it.

That predicate is a named function rather than an inline callback, and it was inline first. routesAreWrapped.test.ts reads the text of each router.post looking for an async that no asyncRoute covers, and an async callback nested inside a wrapped handler looks exactly like an unwrapped one to it. The guard caught it, and hoisting the function out was the better fix: the code reads more clearly and the guard keeps its teeth rather than learning another exception.

Verified: tsc clean for src and tests, lint back to the seven pre-existing warnings with none added, 521 unit tests across 36 suites — seven new, covering the counter policy in both directions.

Not verified: the ceremony cannot be exercised without a browser and a real authenticator, which per #41 is a standing limitation of this feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential.

Closes #39

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:30:17 -05:00

647 lines
28 KiB
TypeScript
Executable File

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<void> {
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<CustomerRecord, 'analytics_consent' | 'analytics_consent_text'>
): 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<IdRow>(`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<CustomerRecord>(
`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<CustomerTokenRow>(
`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<CustomerRecord>(`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<CustomerRecord>(`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<CustomerTokenRow>(
`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<DisabledAtRow>(`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<CustomerRecord>(`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<CustomerRecord>(`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<FavoriteRow>(
`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<IdRow>(`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<CustomerRecord>(
`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<CustomerRecord>(`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<CustomerRecord>(
`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<CustomerRecord>(`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<CustomerRecord>(`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<IdRow>(`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<CustomerRecord>(`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<CustomerOrderRow>(
`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<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows: orderRows } = await pool.query<OrderRecord>(`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;