Files
redefined-designs/backend/src/app.ts
T
bermudalambandClaude Opus 5 1d7aba2d60 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>
2026-08-19 13:12:25 -05:00

82 lines
3.5 KiB
TypeScript
Executable File

import express, { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import path from 'path';
import itemsRouter from './routes/items';
import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRouter } from './routes/cartCheckout';
import adminRouter from './routes/admin';
import adminCustomersRouter from './routes/adminCustomers';
import adminSettingsRouter from './routes/adminSettings';
import adminCategoriesRouter from './routes/adminCategories';
import adminTagsRouter from './routes/adminTags';
import filtersRouter from './routes/filters';
import customersRouter from './routes/customers';
import publicRouter from './routes/public';
import cartRouter from './routes/cart';
import shippingAddressesRouter from './routes/shippingAddresses';
import { attachCustomer } from './middleware/customerAuth';
import { asyncRoute } from './asyncRoute';
const app = express();
// Express advertises itself in X-Powered-By by default, which hands an
// attacker the server stack for free when picking exploits to try.
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter);
app.use(express.json());
app.use(cookieParser());
// Wrapped like any route: attachCustomer awaits a session lookup, and mounted
// globally an unforwarded rejection here would hang every request in the app —
// including the routes that wrap their own handlers correctly.
app.use(asyncRoute(attachCustomer));
app.use('/uploads', express.static(process.env.UPLOADS_DIR || '/app/uploads'));
app.get('/api/config', (_req, res) => {
const clientId = process.env.PAYPAL_CLIENT_ID;
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
res.json({
paypalClientId: isPlaceholder ? null : clientId,
demoMode: process.env.DEMO_MODE !== 'false',
currency: process.env.SITE_CURRENCY || 'USD'
});
});
app.use('/api/items', itemsRouter);
app.use('/api/filters', filtersRouter);
app.use('/api/cart', cartRouter);
app.use('/api/checkout/cart', cartCheckoutRouter);
app.use('/api/admin/customers', adminCustomersRouter);
app.use('/api/admin/settings', adminSettingsRouter);
app.use('/api/admin/categories', adminCategoriesRouter);
app.use('/api/admin/tags', adminTagsRouter);
app.use('/api/admin', adminRouter);
app.use('/api/customers/me/addresses', shippingAddressesRouter);
app.use('/api/customers', customersRouter);
app.use('/', publicRouter);
if (process.env.NODE_ENV !== 'test') {
const staticDir = path.join(__dirname, '..', 'public');
app.use(express.static(staticDir));
app.get('*', (_req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
}
// Mounted last, so it sees errors from every route above. Without it, a route
// that hands an error to next() falls through to Express's default handler,
// and — worse — an async route that rejects never responds at all, leaving the
// client hanging. A hung request is indistinguishable from an empty catalogue
// in the UI, which is exactly how a schema mismatch once read as "the store
// has no items". Always answer.
//
// The four-argument signature is what marks this as error middleware; `next`
// is unused but must stay for Express to recognise it.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal error' });
});
export default app;