[object Object]

← 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

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 →