fix(backend): route every async handler through the error middleware (#59)
Express 4 does not forward a rejected promise from an async handler, so an unwrapped async route never responds at all — the request hangs until the client gives up, nothing reaches the error middleware, and monitoring sees an open connection rather than a 500. That silence is the shape of the 2026-08-17 incident, where an unhandled rejection left every item query hanging and the storefront rendered it as an empty shop. `asyncRoute` was written in response, but it was only applied to some routes: 30 handlers added afterwards were still bare, including register, login, the whole cart, and PayPal checkout. Wraps all 30, plus two the issue's inventory missed. `attachCustomer` is a bare async middleware mounted globally in app.ts, so a rejection in its session lookup would hang every request in the application — including the 25 handlers that were already wrapped correctly, which meant the guarantee did not actually hold anywhere. The PayPal webhook registers on a second router named `webhookRouter`, so an audit grepping for `router.` walked straight past it. Adds a unit test that scans the route sources and fails on any registration whose handler is not wrapped. A convention already half-forgotten once will be forgotten again, and enforcement is what the issue asked for; ESLint would be the better home for it but there is no ESLint in this repo yet (#60). The test walks parens rather than lines, so it also catches a handler whose `async` sits on its own line, and it matches any `*Router` name rather than just `router` — the two ways the existing bare handlers escaped notice. It is deleted along with `asyncRoute` if the project moves to Express 5, which forwards rejections natively. No behaviour changes on the success path; the failure path turns a hung request into a logged 500. Closes #59 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,7 @@ function publicCustomer(c: any) {
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password, name, marketingConsent } = 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' });
|
||||
@@ -83,9 +83,9 @@ router.post('/register', async (req: Request, res: Response) => {
|
||||
const sessionToken = await createSession(customer.id);
|
||||
setSessionCookie(res, sessionToken);
|
||||
res.json(publicCustomer(customer));
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/verify-email', async (req: Request, res: Response) => {
|
||||
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()`,
|
||||
@@ -95,7 +95,7 @@ router.post('/verify-email', async (req: Request, res: Response) => {
|
||||
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]);
|
||||
await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]);
|
||||
res.json({ status: 'verified' });
|
||||
});
|
||||
}));
|
||||
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -196,7 +196,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
res.json(publicCustomer(fresh[0]));
|
||||
}));
|
||||
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
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];
|
||||
@@ -211,14 +211,14 @@ router.post('/login', async (req: Request, res: Response) => {
|
||||
const sessionToken = await createSession(customer.id);
|
||||
setSessionCookie(res, sessionToken);
|
||||
res.json(publicCustomer(customer));
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/logout', async (req: Request, res: Response) => {
|
||||
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(
|
||||
@@ -265,22 +265,22 @@ router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Reques
|
||||
res.json(publicCustomer(rows[0]));
|
||||
}));
|
||||
|
||||
router.get('/me', requireCustomer, async (req: Request, res: Response) => {
|
||||
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(publicCustomer(rows[0]));
|
||||
});
|
||||
}));
|
||||
|
||||
router.put('/me', requireCustomer, async (req: Request, res: Response) => {
|
||||
router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { name } = req.body;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
|
||||
[name || null, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(rows[0]));
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/change-password', requireCustomer, async (req: Request, res: Response) => {
|
||||
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' });
|
||||
@@ -293,18 +293,18 @@ router.post('/change-password', requireCustomer, async (req: Request, res: Respo
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]);
|
||||
res.status(204).end();
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/me/consent', requireCustomer, async (req: Request, res: Response) => {
|
||||
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();
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/me/orders', requireCustomer, async (req: Request, res: Response) => {
|
||||
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
|
||||
@@ -312,9 +312,9 @@ router.get('/me/orders', requireCustomer, async (req: Request, res: Response) =>
|
||||
[req.customerId]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/me/export', requireCustomer, async (req: Request, res: Response) => {
|
||||
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"');
|
||||
@@ -323,13 +323,13 @@ router.get('/me/export', requireCustomer, async (req: Request, res: Response) =>
|
||||
orders: orderRows,
|
||||
exported_at: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
router.delete('/me', requireCustomer, async (req: Request, res: Response) => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user