← back to Costa Rica
costa-rica: wrap all sub-router async routes in an error-forwarding harden(), close the request-hang gap (Cody gate, cycle 11) — TK-10346
16c0a2b6a2afdb8c8b76af8ee318426707078390 · 2026-09-23 21:09:45 -0700 · Steve
GO-LIVE PRE-FLIGHT (systemic, deferred from cycle 10): Express 4 does NOT forward
a rejected promise from an async route handler to error-handling middleware. An
uncaught throw inside `async (req,res)=>{...}` was an unhandledRejection — cycle 10
added a process-level net so that no longer CRASHES the server, but the offending
request still HUNG FOREVER (no response ever sent). This closes the hang.
New lib/async-harden.js: `harden(router)` wraps every ordinary (non-error, arity<4)
route handler in a router's stack so a thrown/rejected handler calls next(err) ->
the app's global error handler returns a clean 500. A handler with its own
try/catch never rejects, so it's an unaffected pass-through (existing try/catch
still wins, no double-response). Idempotent (won't double-wrap).
Wired into server.js: the 5 sub-router mounts (webhooks, /api/app, /api/admin,
/api/build, /api/logo-agent) now go through harden(); a global 4-arg error
handler is mounted LAST (after express.static, before the boot guard), reusing
the existing serverError() pattern (log full detail server-side, generic client
message), guarded on res.headersSent. The ~20 INLINE server.js routes already had
their own try/catch + serverError and are unaffected (this targets the sub-routers,
where the gap actually was).
Cody gate — clears the bar, one required follow-up + one free fix, both done:
- REQUIRED: the original 8 tests proved the MECHANISM against a throwaway
router built inline in the test, never against the real routes/app.js router
— so a regression (server.js losing its harden(...) call) would go uncaught
by CI. Added test/async-harden-real-router.test.js: mounts the ACTUAL router
from routes/app.js through the REAL harden(), drives the genuinely-uncaught
`GET /listings/:slug` (routes/app.js:137, no try/catch) through a rejecting
pool.query, proves the full real chain resolves to a clean 500 in <5s (not a
hang) — plus confirms a normal call through the same hardened route is
unaffected.
- FREE: documented (not fixed — inert today, checked every handler across all
5 routers) the latent next()-then-later-throw double-next limitation shared
with upstream express-async-handler.
- NOTED, not a defect: harden(webhooks router) is a no-op (its handlers already
self-catch) — the fix's value is entirely in app/admin/build/logo-agent.
- Also verified (bonus, pre-existing): server.js:597's `/sitemap.xml` catch
block explicitly calls next(err) — it now lands on this new handler instead
of Express's default (which would have leaked a stack trace in dev).
Tests (+10, suite 160 -> 170): async-harden.test.js (8: asyncWrap sync/async
forwarding, idempotency, E2E throw->500/normal->200/self-caught->own-status) +
async-harden-real-router.test.js (2, the required real-router proof).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
A lib/async-harden.jsM server.jsA test/async-harden-real-router.test.jsA test/async-harden.test.js
Diff
commit 16c0a2b6a2afdb8c8b76af8ee318426707078390
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 23 21:09:45 2026 -0700
costa-rica: wrap all sub-router async routes in an error-forwarding harden(), close the request-hang gap (Cody gate, cycle 11) — TK-10346
GO-LIVE PRE-FLIGHT (systemic, deferred from cycle 10): Express 4 does NOT forward
a rejected promise from an async route handler to error-handling middleware. An
uncaught throw inside `async (req,res)=>{...}` was an unhandledRejection — cycle 10
added a process-level net so that no longer CRASHES the server, but the offending
request still HUNG FOREVER (no response ever sent). This closes the hang.
New lib/async-harden.js: `harden(router)` wraps every ordinary (non-error, arity<4)
route handler in a router's stack so a thrown/rejected handler calls next(err) ->
the app's global error handler returns a clean 500. A handler with its own
try/catch never rejects, so it's an unaffected pass-through (existing try/catch
still wins, no double-response). Idempotent (won't double-wrap).
Wired into server.js: the 5 sub-router mounts (webhooks, /api/app, /api/admin,
/api/build, /api/logo-agent) now go through harden(); a global 4-arg error
handler is mounted LAST (after express.static, before the boot guard), reusing
the existing serverError() pattern (log full detail server-side, generic client
message), guarded on res.headersSent. The ~20 INLINE server.js routes already had
their own try/catch + serverError and are unaffected (this targets the sub-routers,
where the gap actually was).
Cody gate — clears the bar, one required follow-up + one free fix, both done:
- REQUIRED: the original 8 tests proved the MECHANISM against a throwaway
router built inline in the test, never against the real routes/app.js router
— so a regression (server.js losing its harden(...) call) would go uncaught
by CI. Added test/async-harden-real-router.test.js: mounts the ACTUAL router
from routes/app.js through the REAL harden(), drives the genuinely-uncaught
`GET /listings/:slug` (routes/app.js:137, no try/catch) through a rejecting
pool.query, proves the full real chain resolves to a clean 500 in <5s (not a
hang) — plus confirms a normal call through the same hardened route is
unaffected.
- FREE: documented (not fixed — inert today, checked every handler across all
5 routers) the latent next()-then-later-throw double-next limitation shared
with upstream express-async-handler.
- NOTED, not a defect: harden(webhooks router) is a no-op (its handlers already
self-catch) — the fix's value is entirely in app/admin/build/logo-agent.
- Also verified (bonus, pre-existing): server.js:597's `/sitemap.xml` catch
block explicitly calls next(err) — it now lands on this new handler instead
of Express's default (which would have leaked a stack trace in dev).
Tests (+10, suite 160 -> 170): async-harden.test.js (8: asyncWrap sync/async
forwarding, idempotency, E2E throw->500/normal->200/self-caught->own-status) +
async-harden-real-router.test.js (2, the required real-router proof).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
lib/async-harden.js | 63 +++++++++++++++++++++
server.js | 21 +++++--
test/async-harden-real-router.test.js | 64 ++++++++++++++++++++++
test/async-harden.test.js | 100 ++++++++++++++++++++++++++++++++++
4 files changed, 243 insertions(+), 5 deletions(-)
diff --git a/lib/async-harden.js b/lib/async-harden.js
new file mode 100644
index 0000000..237beb9
--- /dev/null
+++ b/lib/async-harden.js
@@ -0,0 +1,63 @@
+'use strict';
+// Async-error safety for Express 4 (Cody gate, cycle 11, TK-10346).
+//
+// Express 4 does NOT forward a rejected promise from an async route handler to
+// error-handling middleware. An uncaught throw inside `async (req,res)=>{...}`
+// therefore becomes an unhandledRejection — which on Node 15+ CRASHES the process
+// (now caught by server.js's net, so the server stays up) and leaves the request
+// HANGING with no response ever sent.
+//
+// `harden(router)` wraps every route handler in a router so a thrown/rejected
+// handler calls next(err) -> the app's global error handler returns a clean 500.
+// A handler that catches its own errors never rejects, so the wrap is a transparent
+// pass-through for it: existing try/catch blocks still win, and there is no
+// double-response. Error-handling middleware (arity 4) is left untouched.
+
+// KNOWN LIMITATION (Cody gate, cycle 11 — same as upstream express-async-handler):
+// if a handler calls next() SYNCHRONOUSLY to advance the chain and THEN, in
+// still-pending async work, throws/rejects, this wrapper will call next(err) a
+// SECOND time after the chain has already moved on — Express's fallback then
+// destroys the socket if headers are already sent. Every handler across the 5
+// hardened routers (auth.js's authRequired/optionalAuth + routes/{app,admin,build,
+// logo-agent,webhooks}.js) was checked at cycle-11 and NONE does this today
+// (authRequired/optionalAuth are fully synchronous — they return undefined or the
+// non-thenable `res`, so no spurious .catch fires). If a future async middleware
+// calls next() and later awaits/throws, this file is the first place to look.
+//
+// Wrap one handler so BOTH a synchronous throw and a rejected promise reach next().
+function asyncWrap(handle) {
+ return function hardened(req, res, next) {
+ let out;
+ try {
+ out = handle(req, res, next);
+ } catch (e) {
+ next(e); // synchronous throw
+ return;
+ }
+ if (out && typeof out.then === 'function') {
+ Promise.resolve(out).catch(next); // async rejection
+ }
+ return out;
+ };
+}
+
+// Wrap every route handler in `router.stack`. Idempotent (won't double-wrap) so it
+// is safe to call more than once on the same router. Returns the router for
+// inline use: app.use('/x', harden(require('./routes/x'))).
+function harden(router) {
+ if (!router || !Array.isArray(router.stack)) return router;
+ for (const layer of router.stack) {
+ const routeStack = layer.route && layer.route.stack;
+ if (!Array.isArray(routeStack)) continue; // a mounted sub-router / plain middleware layer — skip
+ for (const h of routeStack) {
+ // Only wrap ordinary handlers (arity < 4); never error-handling middleware.
+ if (typeof h.handle === 'function' && h.handle.length < 4 && !h.handle.__hardened) {
+ h.handle = asyncWrap(h.handle);
+ h.handle.__hardened = true;
+ }
+ }
+ }
+ return router;
+}
+
+module.exports = { asyncWrap, harden };
diff --git a/server.js b/server.js
index 8909910..b1f1328 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const express = require('express');
const basicAuth = require('express-basic-auth');
const { Pool } = require('pg');
const path = require('path');
+const { harden } = require('./lib/async-harden'); // forward async route throws -> error handler (Express 4)
const PORT = parseInt(process.env.PORT || '9791', 10);
const SITE_NAME = process.env.SITE_NAME || 'Costa Rica Directory';
@@ -47,7 +48,7 @@ app.use('/api/app', (req, res, next) => {
// Payment/WhatsApp webhooks need the RAW body for HMAC verification, so mount
// them BEFORE the global JSON parser (and before the site basic-auth gate).
-app.use('/webhooks', require('./routes/webhooks'));
+app.use('/webhooks', harden(require('./routes/webhooks')));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
@@ -62,7 +63,7 @@ app.use((req, res, next) => {
// Mobile-app API (JWT-authed) — mounted BEFORE the site basic-auth gate so
// public app users can reach it.
-app.use('/api/app', require('./routes/app').router);
+app.use('/api/app', harden(require('./routes/app').router));
app.get('/api/app/health', (_req, res) => {
const { getProvider } = require('./lib/payments');
res.json({ ok: true, layer: 'marketplace',
@@ -94,12 +95,12 @@ if (BA_USER && BA_PASS) {
app.get('/health', (_req, res) => res.json({ ok: true, site: SITE_NAME, ts: new Date().toISOString() }));
// Admin (behind the basic-auth gate) — host-claim approvals + bookings oversight.
-app.use('/api/admin', require('./routes/admin'));
+app.use('/api/admin', harden(require('./routes/admin')));
app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
-app.use('/api/build', require('./routes/build'));
+app.use('/api/build', harden(require('./routes/build')));
app.get('/build', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'build.html')));
// Logo Agent — hot-or-not tournament brand/logo builder (admin-gated curation tool)
-app.use('/api/logo-agent', require('./routes/logo-agent'));
+app.use('/api/logo-agent', harden(require('./routes/logo-agent')));
app.get('/logo-agent', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'logo-agent.html')));
// Map of all geocoded places (desktop viewer)
app.get('/api/map', async (req, res) => {
@@ -712,6 +713,16 @@ app.use((req, res, next) => {
app.use(express.static(path.join(__dirname, 'public')));
+// Global error handler — MUST be last (4-arg). Catches next(err) forwarded by the
+// harden()'d sub-routers (an uncaught async throw in any route) and returns a clean,
+// generic 500 instead of a hung request. res.headersSent guard avoids a
+// double-response if a handler already started replying before throwing.
+// (Cody gate, cycle 11, TK-10346.)
+app.use((err, req, res, next) => {
+ if (res.headersSent) return next(err);
+ serverError(res, err, `${req.method} ${req.path}`);
+});
+
// TK-10346 — fail-closed boot guard: refuse to serve in production if a payment/WhatsApp
// integration is LIVE without its webhook secret (would silently reject every real webhook
// -> payments succeed but bookings never confirm). Inert while everything is sandbox.
diff --git a/test/async-harden-real-router.test.js b/test/async-harden-real-router.test.js
new file mode 100644
index 0000000..38daf60
--- /dev/null
+++ b/test/async-harden-real-router.test.js
@@ -0,0 +1,64 @@
+'use strict';
+// Cody gate follow-up (cycle 11, TK-10346): async-harden.test.js proved the
+// MECHANISM against a throwaway router built inline in the test. Cody's one
+// required gap: nothing proved the WIRING against the REAL routes/app.js router —
+// so a future regression (e.g. server.js losing its harden(...) call, or a route
+// losing an inner try/catch it used to have) would go uncaught by CI. This test
+// mounts the ACTUAL router from routes/app.js, wraps it with the real harden()
+// exactly as server.js does, and drives a genuinely-uncaught route (verified by
+// reading routes/app.js: `GET /listings/:slug` at line 137 has no try/catch around
+// its pool.query) through a rejecting pool.query to prove the full real chain:
+// real route -> real harden() -> real error middleware -> clean 500, not a hang.
+//
+// No live DB: db.pool.query is swapped to reject on demand. No live creds needed
+// for anything else the router touches on this path.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const db = require('../lib/db');
+const { harden } = require('../lib/async-harden');
+const { router } = require('../routes/app'); // the REAL router, unmodified
+
+const origQuery = db.pool.query;
+let server, base;
+
+before(async () => {
+ const app = express();
+ app.use(express.json());
+ // Mirror server.js's exact wiring: app.use('/api/app', harden(require('./routes/app').router))
+ // followed by the global error handler mounted last.
+ app.use('/api/app', harden(router));
+ app.use((err, req, res, _next) => {
+ if (res.headersSent) return;
+ console.error(`[500] ${req.method} ${req.path}`, err && err.message);
+ res.status(500).json({ ok: false, error: 'internal error' });
+ });
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; server && server.close(); });
+
+function get(path) {
+ return new Promise((resolve, reject) => {
+ http.get(base + path, res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); }).on('error', reject);
+ });
+}
+
+test('REAL ROUTER: GET /listings/:slug (genuinely uncaught in routes/app.js) -> a rejecting pool.query returns a clean 500, not a hang', async () => {
+ db.pool.query = async () => { throw new Error('simulated DB outage'); };
+ const started = Date.now();
+ const r = await get('/api/app/listings/some-place');
+ const elapsed = Date.now() - started;
+ assert.equal(r.status, 500, 'the REAL route, hardened via the REAL harden(), reaches the REAL error middleware');
+ assert.equal(r.json.error, 'internal error', 'generic message, no DB detail leaked');
+ assert.ok(elapsed < 5000, `resolved in ${elapsed}ms — proves this is not a hang (the pre-fix behavior)`);
+});
+
+test('REAL ROUTER: a normal (non-throwing) call to the same real route still works through harden()', async () => {
+ db.pool.query = async () => ({ rows: [] }); // no listing found — the route's own 404, not the error path
+ const r = await get('/api/app/listings/nonexistent-place');
+ assert.equal(r.status, 404, 'hardening a route that behaves normally does not change its normal responses');
+});
diff --git a/test/async-harden.test.js b/test/async-harden.test.js
new file mode 100644
index 0000000..b90e1bb
--- /dev/null
+++ b/test/async-harden.test.js
@@ -0,0 +1,100 @@
+'use strict';
+// harden()/asyncWrap() — Express 4 async-error safety (Cody gate, cycle 11, TK-10346).
+// Proves an uncaught throw/rejection in a hardened route reaches error middleware
+// (clean 500, no hang, no crash), a normal route is unaffected, and a route that
+// catches its own error keeps its own response (try/catch still wins).
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+const { asyncWrap, harden } = require('../lib/async-harden');
+
+// --- unit: asyncWrap forwards both sync throws and async rejections ---
+test('asyncWrap: a synchronous throw is forwarded to next(err)', () => {
+ let forwarded = null;
+ const wrapped = asyncWrap(() => { throw new Error('sync-boom'); });
+ wrapped({}, {}, (e) => { forwarded = e; });
+ assert.ok(forwarded && /sync-boom/.test(forwarded.message));
+});
+
+test('asyncWrap: an async rejection is forwarded to next(err)', async () => {
+ let forwarded = null;
+ const wrapped = asyncWrap(async () => { throw new Error('async-boom'); });
+ wrapped({}, {}, (e) => { forwarded = e; });
+ await new Promise(r => setImmediate(r)); // let the rejection settle
+ assert.ok(forwarded && /async-boom/.test(forwarded.message));
+});
+
+test('asyncWrap: a handler that resolves normally does NOT call next with an error', async () => {
+ let nextErr = 'unset';
+ const wrapped = asyncWrap(async (req, res) => { res.ok = true; });
+ const res = {};
+ wrapped({}, res, (e) => { nextErr = e; });
+ await new Promise(r => setImmediate(r));
+ assert.equal(res.ok, true);
+ assert.equal(nextErr, 'unset', 'next(err) not called on success');
+});
+
+test('harden: is idempotent — a second call does not double-wrap', () => {
+ const router = express.Router();
+ router.get('/x', async (_req, res) => res.end());
+ harden(router);
+ const h1 = router.stack.find(l => l.route && l.route.path === '/x').route.stack[0].handle;
+ harden(router);
+ const h2 = router.stack.find(l => l.route && l.route.path === '/x').route.stack[0].handle;
+ assert.strictEqual(h1, h2, 'handler is wrapped once, not re-wrapped');
+ assert.equal(h1.__hardened, true);
+});
+
+// --- E2E: a hardened sub-router behind a global error handler ---
+let server, base;
+before(async () => {
+ const router = express.Router();
+ router.get('/boom', async () => { throw new Error('kaboom — uncaught in an async route'); });
+ router.get('/boom-sync', (_req, _res) => { throw new Error('sync kaboom'); });
+ router.get('/ok', async (_req, res) => res.json({ ok: true, hi: 'there' }));
+ router.get('/caught', async (_req, res) => {
+ try { throw new Error('handled internally'); }
+ catch { res.status(400).json({ ok: false, caught: true }); }
+ });
+ harden(router);
+
+ const app = express();
+ app.use('/api', router);
+ // Global error handler mounted last, mirroring server.js.
+ app.use((err, req, res, _next) => { res.status(500).json({ ok: false, error: 'internal error', path: req.path }); });
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => server && server.close());
+
+function get(path) {
+ return new Promise((resolve, reject) => {
+ http.get(base + path, res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); }).on('error', reject);
+ });
+}
+
+test('E2E: an uncaught ASYNC throw -> clean 500 (not a hang, not a crash)', async () => {
+ const r = await get('/api/boom');
+ assert.equal(r.status, 500);
+ assert.equal(r.json.ok, false);
+ assert.equal(r.json.error, 'internal error', 'generic message, no internal detail leaked');
+});
+
+test('E2E: an uncaught SYNC throw -> clean 500 too', async () => {
+ const r = await get('/api/boom-sync');
+ assert.equal(r.status, 500);
+});
+
+test('E2E: a normal route is unaffected by harden', async () => {
+ const r = await get('/api/ok');
+ assert.equal(r.status, 200);
+ assert.deepEqual(r.json, { ok: true, hi: 'there' });
+});
+
+test('E2E: a route that catches its own error keeps its own response (try/catch still wins, not a 500)', async () => {
+ const r = await get('/api/caught');
+ assert.equal(r.status, 400, 'the handler self-handled -> its 400, not the error handler 500');
+ assert.equal(r.json.caught, true);
+});
← 5c5fc34 cycle 10 docs: YOLO_NOTES ledger — plaid.js fetch bound + un
·
back to Costa Rica
·
cycle 11 docs: YOLO_NOTES ledger — systemic async-error hard 47088ae →