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:
2026-08-19 13:12:25 -05:00
co-authored by Claude Opus 5
parent 07247caab4
commit 1d7aba2d60
9 changed files with 226 additions and 63 deletions
+10 -10
View File
@@ -140,7 +140,7 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
res.json(rows);
}));
router.post('/items', uploadImages, async (req: Request, res: Response) => {
router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const categoryId = readCategoryId(req.body.category_id);
@@ -180,9 +180,9 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const categoryId = readCategoryId(req.body.category_id);
@@ -233,7 +233,7 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
const itemId = Number(req.params.id);
@@ -251,12 +251,12 @@ router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
res.status(204).end();
}));
router.delete('/items/:id/images/:imageId', async (req: Request, res: Response) => {
router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: Response) => {
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
res.status(204).end();
});
}));
router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
[req.params.id]
@@ -265,15 +265,15 @@ router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
// customer, so everyone watching it hears about it.
await notifyFavoritersOfSale([Number(req.params.id)], null);
res.json(rows[0]);
});
}));
router.post('/items/:id/mark-available', async (req: Request, res: Response) => {
router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
WHERE id=$1 RETURNING *`,
[req.params.id]
);
res.json(rows[0]);
});
}));
export default router;
+5 -4
View File
@@ -1,18 +1,19 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(`SELECT key, value FROM admin_settings`);
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
res.json({
cartExpiryHours: parseFloat(map.cart_expiry_hours || '24')
});
});
}));
router.put('/', async (req: Request, res: Response) => {
router.put('/', asyncRoute(async (req: Request, res: Response) => {
const { cartExpiryHours } = req.body;
const hours = parseFloat(cartExpiryHours);
if (Number.isNaN(hours) || hours <= 0) {
@@ -24,6 +25,6 @@ router.put('/', async (req: Request, res: Response) => {
[String(hours)]
);
res.json({ cartExpiryHours: hours });
});
}));
export default router;
+7 -6
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
const router = Router();
@@ -26,14 +27,14 @@ const CART_ITEM_SELECT = `
ORDER BY ci.added_at DESC
`;
router.get('/', requireCustomer, async (req: Request, res: Response) => {
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows: cartRows } = await pool.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
if (!cartRows.length) return res.json({ items: [] });
const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]);
res.json({ items });
});
}));
router.post('/items/:itemId', requireCustomer, async (req: Request, res: Response) => {
router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const itemId = req.params.itemId;
const client = await pool.connect();
try {
@@ -75,9 +76,9 @@ router.post('/items/:itemId', requireCustomer, async (req: Request, res: Respons
} finally {
client.release();
}
});
}));
router.delete('/items/:itemId', requireCustomer, async (req: Request, res: Response) => {
router.delete('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const itemId = req.params.itemId;
const client = await pool.connect();
try {
@@ -103,6 +104,6 @@ router.delete('/items/:itemId', requireCustomer, async (req: Request, res: Respo
} finally {
client.release();
}
});
}));
export default router;
+9 -8
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { notifyFavoritersOfSale } from '../favoriteAlerts';
@@ -93,7 +94,7 @@ async function openCheckout(
return { ok: true, checkoutId, cart };
}
router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => {
router.post('/paypal/create', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { shippingAddressId } = req.body;
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
@@ -139,7 +140,7 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons
} finally {
client.release();
}
});
}));
// Returns the sold item ids and the buyer, so the caller can notify favoriters
// *after* COMMIT. Sending inside the transaction would email people about a
@@ -169,7 +170,7 @@ async function completeCheckout(client: any, checkoutId: number, processor: stri
};
}
router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => {
router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { orderID } = req.body;
const client = await pool.connect();
try {
@@ -201,9 +202,9 @@ router.post('/paypal/capture', requireCustomer, async (req: Request, res: Respon
} finally {
client.release();
}
});
}));
router.post('/demo/purchase', requireCustomer, async (req: Request, res: Response) => {
router.post('/demo/purchase', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
if (process.env.DEMO_MODE === 'false') return res.status(403).json({ error: 'demo mode disabled' });
const { shippingAddressId } = req.body;
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
@@ -225,9 +226,9 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons
} finally {
client.release();
}
});
}));
webhookRouter.post('/', async (req: Request, res: Response) => {
webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
try {
const token = await getAccessToken();
const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, {
@@ -272,6 +273,6 @@ webhookRouter.post('/', async (req: Request, res: Response) => {
console.error('webhook error', err);
res.status(500).end();
}
});
}));
export { router, webhookRouter };
+22 -22
View File
@@ -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;
+3 -2
View File
@@ -1,9 +1,10 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
router.get('/unsubscribe', async (req: Request, res: Response) => {
router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
const token = req.query.token as string;
const { rows } = await pool.query(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]);
if (!rows.length) {
@@ -16,6 +17,6 @@ router.get('/unsubscribe', async (req: Request, res: Response) => {
[rows[0].id]
);
res.send('<html><body><h2>You\'ve been unsubscribed.</h2><p>You will no longer receive marketing emails from Redefined Designs.</p></body></html>');
});
}));
export default router;
+11 -10
View File
@@ -1,19 +1,20 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { validateAddress, uspsConfigured, UspsValidationResult } from '../usps';
const router = Router();
router.get('/', requireCustomer, async (req: Request, res: Response) => {
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`,
[req.customerId]
);
res.json(rows);
});
}));
router.post('/', requireCustomer, async (req: Request, res: Response) => {
router.post('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body;
if (!fullName || !addressLine1 || !city || !state || !postalCode) {
return res.status(400).json({ error: 'fullName, addressLine1, city, state, and postalCode are required' });
@@ -48,9 +49,9 @@ if ((country || 'US') === 'US') {
} finally {
client.release();
}
});
}));
router.put('/:id', requireCustomer, async (req: Request, res: Response) => {
router.put('/:id', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body;
const client = await pool.connect();
try {
@@ -75,14 +76,14 @@ router.put('/:id', requireCustomer, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.delete('/:id', requireCustomer, async (req: Request, res: Response) => {
router.delete('/:id', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
await pool.query(`DELETE FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, [req.params.id, req.customerId]);
res.status(204).end();
});
}));
router.post('/:id/set-default', requireCustomer, async (req: Request, res: Response) => {
router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -99,6 +100,6 @@ router.post('/:id/set-default', requireCustomer, async (req: Request, res: Respo
} finally {
client.release();
}
});
}));
export default router;