← back to Costa Rica
costa-rica: bound Plaid fetch + fix uncaught-throw/false-verified holes in the host bank-linking path (Cody gate, cycle 10) — TK-10346
8c3bc3c6c939d90433007b68e7361f27caef0fbe · 2026-09-23 20:37:36 -0700 · Steve
Cycle-10 audit of lib/plaid.js (Plaid ACH bank-linking for foreign hosts).
FINDING A — unbounded fetch (fixed): lib/plaid.js _post used raw fetch() with no
timeout, the last unbounded live provider call. Routed through the shared fetchT
(bounds connect+headers AND the body read, like tilopay/onvo). Closes the
PRE-FLIGHT #4/#5 class for Plaid.
FINDING B — uncaught async throws crash the process (fixed for this path): the
Plaid routes had no try/catch and this router has NO error middleware (Express 4),
so on Node 26 a live Plaid throw is an unhandledRejection that CRASHES the whole
marketplace. Wrapped /host/plaid/link-token + /host/plaid/exchange -> clean
502 (504 on PROVIDER_TIMEOUT).
Cody's gate found two more holes INSIDE the same function's blast radius + one
free global net — all fixed this cycle rather than split:
1. /host/plaid/exchange's two pool.query calls (INSERT payout_method, UPDATE
hosts) were still bare awaits below the fix — a DB throw there crashed the
process the same way. Wrapped -> 502.
2. getAuth().catch(()=>({accounts:[]})) swallowed a verification failure, then
persisted the payout method verified=TRUE with a NULL account_id/last4 — a
silently-verified bank with no payout destination. Now `verified` reflects
reality (!!acct.account_id); an unverified method is is_default=FALSE and is
NEVER set as the host default (so a future ACH payout can't target a bank
with no account).
3. Client error messages echoed Plaid's error_code + the plaid.com URL to the
caller (CWE-209). Now log full detail server-side, return a generic message.
4. Added a global process.on('unhandledRejection') net in server.js: log + stay
up, so an uncaught throw in ANY of the ~21 still-unwrapped async routes fails
that one request instead of crashing the whole server. The proper per-route
asyncHandler + error-middleware refactor is a separate scheduled cycle.
Tests (+9, suite 151 -> 160):
- plaid-lib-timeout.test.js (3): stalled body -> PROVIDER_TIMEOUT; 4xx fails
closed; sandbox no-fetch.
- plaid-routes.test.js (6): link-token error->502 (+no leak), timeout->504,
exchange error->502 (no method persisted), getAuth-fail->verified=FALSE + not
default, getAuth-ok->verified=TRUE + default, DB-throw-on-INSERT->502.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
M lib/plaid.jsM routes/app.jsM server.jsA test/plaid-lib-timeout.test.jsA test/plaid-routes.test.js
Diff
commit 8c3bc3c6c939d90433007b68e7361f27caef0fbe
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 23 20:37:36 2026 -0700
costa-rica: bound Plaid fetch + fix uncaught-throw/false-verified holes in the host bank-linking path (Cody gate, cycle 10) — TK-10346
Cycle-10 audit of lib/plaid.js (Plaid ACH bank-linking for foreign hosts).
FINDING A — unbounded fetch (fixed): lib/plaid.js _post used raw fetch() with no
timeout, the last unbounded live provider call. Routed through the shared fetchT
(bounds connect+headers AND the body read, like tilopay/onvo). Closes the
PRE-FLIGHT #4/#5 class for Plaid.
FINDING B — uncaught async throws crash the process (fixed for this path): the
Plaid routes had no try/catch and this router has NO error middleware (Express 4),
so on Node 26 a live Plaid throw is an unhandledRejection that CRASHES the whole
marketplace. Wrapped /host/plaid/link-token + /host/plaid/exchange -> clean
502 (504 on PROVIDER_TIMEOUT).
Cody's gate found two more holes INSIDE the same function's blast radius + one
free global net — all fixed this cycle rather than split:
1. /host/plaid/exchange's two pool.query calls (INSERT payout_method, UPDATE
hosts) were still bare awaits below the fix — a DB throw there crashed the
process the same way. Wrapped -> 502.
2. getAuth().catch(()=>({accounts:[]})) swallowed a verification failure, then
persisted the payout method verified=TRUE with a NULL account_id/last4 — a
silently-verified bank with no payout destination. Now `verified` reflects
reality (!!acct.account_id); an unverified method is is_default=FALSE and is
NEVER set as the host default (so a future ACH payout can't target a bank
with no account).
3. Client error messages echoed Plaid's error_code + the plaid.com URL to the
caller (CWE-209). Now log full detail server-side, return a generic message.
4. Added a global process.on('unhandledRejection') net in server.js: log + stay
up, so an uncaught throw in ANY of the ~21 still-unwrapped async routes fails
that one request instead of crashing the whole server. The proper per-route
asyncHandler + error-middleware refactor is a separate scheduled cycle.
Tests (+9, suite 151 -> 160):
- plaid-lib-timeout.test.js (3): stalled body -> PROVIDER_TIMEOUT; 4xx fails
closed; sandbox no-fetch.
- plaid-routes.test.js (6): link-token error->502 (+no leak), timeout->504,
exchange error->502 (no method persisted), getAuth-fail->verified=FALSE + not
default, getAuth-ok->verified=TRUE + default, DB-throw-on-INSERT->502.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
lib/plaid.js | 6 ++-
routes/app.js | 39 ++++++++++----
server.js | 13 +++++
test/plaid-lib-timeout.test.js | 76 +++++++++++++++++++++++++++
test/plaid-routes.test.js | 116 +++++++++++++++++++++++++++++++++++++++++
5 files changed, 240 insertions(+), 10 deletions(-)
diff --git a/lib/plaid.js b/lib/plaid.js
index c29c92a..b2be018 100644
--- a/lib/plaid.js
+++ b/lib/plaid.js
@@ -7,6 +7,7 @@
// Env (gated): PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV (sandbox|production)
const crypto = require('crypto');
+const { fetchT } = require('./payments/http'); // bound every live Plaid call (no infinite hang)
const CLIENT_ID = process.env.PLAID_CLIENT_ID || '';
const SECRET = process.env.PLAID_SECRET || '';
const ENV = process.env.PLAID_ENV || 'sandbox';
@@ -14,7 +15,10 @@ const LIVE = !!(CLIENT_ID && SECRET);
const BASE = `https://${ENV}.plaid.com`;
async function _post(path, body) {
- const res = await fetch(BASE + path, {
+ // fetchT bounds connect+headers AND the body read (shared with tilopay/onvo) so a
+ // hung Plaid connection during host bank-linking can't leave the request pending
+ // forever — closes the last unbounded live provider fetch (PRE-FLIGHT #4/#5 class).
+ const res = await fetchT(BASE + path, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: CLIENT_ID, secret: SECRET, ...body }),
});
diff --git a/routes/app.js b/routes/app.js
index dfbf002..6df152f 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -503,20 +503,41 @@ router.post('/host/payout-methods', authRequired, async (req, res) => {
// Plaid (foreign hosts): Link token + exchange
router.post('/host/plaid/link-token', authRequired, async (req, res) => {
const host = await requireHost(req, res); if (!host) return;
- const t = await plaid.createLinkToken(req.user.sub);
+ // A live Plaid error/timeout throws; catch it so it becomes a clean gateway error,
+ // NOT an uncaught async rejection (this router has no error middleware, so an
+ // uncaught throw would hang the request / crash the process on Node's
+ // unhandledRejection). See YOLO_NOTES: the systemic 23-route gap is a separate cycle.
+ let t;
+ try { t = await plaid.createLinkToken(req.user.sub); }
+ catch (e) { console.error('[plaid] link-token', e.message); return bad(res, e.code === 'PROVIDER_TIMEOUT' ? 504 : 502, 'bank linking is temporarily unavailable'); }
ok(res, { link_token: t.link_token, env: plaid.ENV, live: plaid.liveMode });
});
router.post('/host/plaid/exchange', authRequired, async (req, res) => {
const host = await requireHost(req, res); if (!host) return;
- const ex = await plaid.exchangePublicToken(req.body?.public_token);
- const auth = await plaid.getAuth(ex.access_token).catch(() => ({ accounts: [] }));
+ let ex;
+ try { ex = await plaid.exchangePublicToken(req.body?.public_token); }
+ catch (e) { console.error('[plaid] exchange', e.message); return bad(res, e.code === 'PROVIDER_TIMEOUT' ? 504 : 502, 'bank linking is temporarily unavailable'); }
+ // getAuth fetches the account (account_id + last4) — the payout DESTINATION. If it
+ // fails, do NOT persist a payout method as verified=TRUE with a null account (a
+ // silent lie that a later ACH payout would have nothing to send to). Record it
+ // unverified and never make it the host's default. (Cody gate, cycle 10.)
+ let auth;
+ try { auth = await plaid.getAuth(ex.access_token); }
+ catch (e) { console.error('[plaid] getAuth', e.message); auth = { accounts: [] }; }
const acct = auth.accounts?.[0] || {};
- const { rows: [pm] } = await pool.query(
- `INSERT INTO payout_methods (host_id, kind, label, plaid_item_id, plaid_access_token, plaid_account_id, account_last4, currency, verified, is_default)
- VALUES ($1,'plaid_ach','Bank (Plaid)',$2,$3,$4,$5,'USD',TRUE,TRUE) RETURNING id`,
- [host.id, ex.item_id, ex.access_token, acct.account_id || null, acct.mask || null]);
- await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, host.id]);
- ok(res, { payout_method_id: pm.id, last4: acct.mask, sandbox: !!ex.sandbox });
+ const verified = !!acct.account_id;
+ // Guard the DB writes too: this router has no error middleware, so a bare
+ // pool.query throw here would crash the process (Node unhandledRejection).
+ let pm;
+ try {
+ const ins = await pool.query(
+ `INSERT INTO payout_methods (host_id, kind, label, plaid_item_id, plaid_access_token, plaid_account_id, account_last4, currency, verified, is_default)
+ VALUES ($1,'plaid_ach','Bank (Plaid)',$2,$3,$4,$5,'USD',$6,$6) RETURNING id`,
+ [host.id, ex.item_id, ex.access_token, acct.account_id || null, acct.mask || null, verified]);
+ pm = ins.rows[0];
+ if (verified) await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, host.id]);
+ } catch (e) { console.error('[plaid] persist payout method', e.message); return bad(res, 502, 'could not save bank details'); }
+ ok(res, { payout_method_id: pm.id, last4: acct.mask, verified, sandbox: !!ex.sandbox });
});
module.exports = { router, confirmBooking, normalizePhone };
diff --git a/server.js b/server.js
index d8b6a76..8909910 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,19 @@ function serverError(res, e, where) {
res.status(500).json({ error: 'internal error' });
}
+// Safety net (Cody gate, cycle 10): this app's routes are async and there is no
+// per-route asyncHandler / error middleware yet, so an uncaught throw inside a
+// route (a DB blip, an unbounded external call) becomes an unhandledRejection —
+// which on Node 15+ CRASHES the whole process by default, taking down the entire
+// marketplace for one route's error. Log and STAY UP: the offending request still
+// fails (its response was never sent), but every other in-flight + future request
+// survives. This converts a server-wide crash into a single failed request. The
+// proper fix — wrap all routes in an asyncHandler that forwards to error
+// middleware — is a separate, review-worthy cycle (tracked in YOLO_NOTES).
+process.on('unhandledRejection', (reason) => {
+ console.error('[unhandledRejection]', reason && (reason.stack || reason.message || reason));
+});
+
const app = express();
app.set('trust proxy', true);
diff --git a/test/plaid-lib-timeout.test.js b/test/plaid-lib-timeout.test.js
new file mode 100644
index 0000000..9ca26ac
--- /dev/null
+++ b/test/plaid-lib-timeout.test.js
@@ -0,0 +1,76 @@
+'use strict';
+// lib/plaid.js now routes every live call through fetchT (PRE-FLIGHT #4/#5 class) —
+// a hung Plaid connection during host bank-linking can no longer leave the request
+// pending forever. Proves the wiring: a header-fast/body-stalled response rejects
+// with PROVIDER_TIMEOUT, and a Plaid HTTP error fails closed (throws), never
+// fabricating a token. Forces LIVE via env + require-cache reset; faked fetch.
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+const PLAID = require.resolve('../lib/plaid');
+const realFetch = global.fetch;
+
+function loadLivePlaid() {
+ process.env.PLAID_CLIENT_ID = 'cid';
+ process.env.PLAID_SECRET = 'sec';
+ delete require.cache[PLAID];
+ return require(PLAID);
+}
+function unloadLivePlaid() {
+ delete process.env.PLAID_CLIENT_ID;
+ delete process.env.PLAID_SECRET;
+ delete require.cache[PLAID];
+}
+
+test('plaid createLinkToken(): a body-stalled Plaid response rejects PROVIDER_TIMEOUT (not an infinite hang)', async () => {
+ process.env.PROVIDER_HTTP_TIMEOUT_MS = '40';
+ const plaid = loadLivePlaid();
+ global.fetch = (url, opts) => Promise.resolve({
+ ok: true, status: 200,
+ json: () => new Promise((_resolve, reject) => {
+ opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
+ }),
+ });
+ try {
+ assert.equal(plaid.liveMode, true, 'live mode for this test');
+ await assert.rejects(
+ () => plaid.createLinkToken(42),
+ (err) => { assert.equal(err.code, 'PROVIDER_TIMEOUT'); return true; },
+ );
+ } finally {
+ global.fetch = realFetch;
+ unloadLivePlaid();
+ delete process.env.PROVIDER_HTTP_TIMEOUT_MS;
+ }
+});
+
+test('plaid createLinkToken(): a Plaid HTTP error fails closed (throws, no token fabricated)', async () => {
+ const plaid = loadLivePlaid();
+ global.fetch = () => Promise.resolve({ ok: false, status: 400, json: async () => ({ error_code: 'INVALID_API_KEYS' }) });
+ try {
+ await assert.rejects(
+ () => plaid.createLinkToken(42),
+ (err) => { assert.match(err.message, /plaid .* HTTP 400/); assert.match(err.message, /INVALID_API_KEYS/); return true; },
+ );
+ } finally {
+ global.fetch = realFetch;
+ unloadLivePlaid();
+ }
+});
+
+test('plaid createLinkToken(): sandbox (no creds) returns a deterministic fake token, no fetch', async () => {
+ delete require.cache[PLAID];
+ const plaid = require(PLAID); // no creds -> liveMode false
+ let fetched = false;
+ global.fetch = () => { fetched = true; return Promise.reject(new Error('should not fetch in sandbox')); };
+ try {
+ const t = await plaid.createLinkToken(42);
+ assert.equal(plaid.liveMode, false);
+ assert.match(t.link_token, /^link-sandbox-/);
+ assert.equal(fetched, false, 'sandbox never hits the network');
+ } finally {
+ global.fetch = realFetch;
+ delete require.cache[PLAID];
+ }
+});
diff --git a/test/plaid-routes.test.js b/test/plaid-routes.test.js
new file mode 100644
index 0000000..a7d5f78
--- /dev/null
+++ b/test/plaid-routes.test.js
@@ -0,0 +1,116 @@
+'use strict';
+// The Plaid routes in routes/app.js now catch a live Plaid throw and return a clean
+// gateway error (502, or 504 on PROVIDER_TIMEOUT) — instead of an UNCAUGHT async
+// rejection. This router has no error middleware (Express 4), so an uncaught throw
+// would hang the request and crash the process on Node's unhandledRejection. These
+// tests prove the route converts the throw to a response.
+// (The systemic 23-route uncaught-async gap is tracked separately — see YOLO_NOTES.)
+//
+// No real DB/network: pool.query is a queue mock, plaid methods are stubbed on the
+// shared module (no require-cache reset, so the app router's plaid ref stays valid).
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const { signToken } = require('../lib/auth');
+const db = require('../lib/db');
+const plaid = require('../lib/plaid');
+const { router } = require('../routes/app');
+
+let responses = [];
+let calls = [];
+const origQuery = db.pool.query;
+const origLink = plaid.createLinkToken;
+const origExchange = plaid.exchangePublicToken;
+const origGetAuth = plaid.getAuth;
+
+let server, base;
+before(async () => {
+ db.pool.query = async (sql, args) => { calls.push({ sql, args }); if (responses.length) { const r = responses.shift(); if (r instanceof Error) throw r; return r; } return { rows: [], rowCount: 0 }; };
+ const app = express();
+ app.use(express.json());
+ app.use('/api/app', router);
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; plaid.createLinkToken = origLink; plaid.exchangePublicToken = origExchange; plaid.getAuth = origGetAuth; server && server.close(); });
+const hasSql = (re) => calls.some(c => re.test(c.sql));
+const findCall = (re) => calls.find(c => re.test(c.sql));
+
+function post(path, body, token) {
+ const data = JSON.stringify(body);
+ return new Promise((resolve, reject) => {
+ const r = http.request(base + path, { method: 'POST', headers: {
+ 'content-type': 'application/json', 'content-length': Buffer.byteLength(data),
+ ...(token ? { authorization: 'Bearer ' + token } : {}) } },
+ res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
+ r.on('error', reject); r.end(data);
+ });
+}
+
+const HOST_ROW = { rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }; // requireHost
+
+test('POST /host/plaid/link-token: a Plaid ERROR becomes a 502 (not an uncaught crash/hang)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ responses = [HOST_ROW]; calls = [];
+ plaid.createLinkToken = async () => { throw new Error('plaid /link/token/create HTTP 400: INVALID_API_KEYS'); };
+ const r = await post('/api/app/host/plaid/link-token', {}, token);
+ assert.equal(r.status, 502, 'a Plaid error returns a clean 502');
+ assert.equal(r.json.ok, false);
+ assert.equal(/INVALID_API_KEYS|plaid\.com|link\/token/.test(JSON.stringify(r.json)), false, 'no internal Plaid detail leaked to the client');
+});
+
+test('POST /host/plaid/link-token: a Plaid TIMEOUT becomes a 504', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ responses = [HOST_ROW]; calls = [];
+ plaid.createLinkToken = async () => { const e = new Error('provider fetch timeout after 15000ms'); e.code = 'PROVIDER_TIMEOUT'; throw e; };
+ const r = await post('/api/app/host/plaid/link-token', {}, token);
+ assert.equal(r.status, 504, 'a Plaid timeout returns 504 Gateway Timeout');
+});
+
+test('POST /host/plaid/exchange: a Plaid ERROR on exchange becomes a 502 (before any DB write)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ responses = [HOST_ROW]; calls = [];
+ plaid.exchangePublicToken = async () => { throw new Error('plaid /item/public_token/exchange HTTP 400: INVALID_PUBLIC_TOKEN'); };
+ const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-bad' }, token);
+ assert.equal(r.status, 502, 'a failed exchange returns 502, not an uncaught throw');
+ assert.equal(hasSql(/INSERT INTO payout_methods/), false, 'no payout method persisted when exchange fails');
+});
+
+test('POST /host/plaid/exchange: getAuth failure -> payout method saved verified=FALSE, NOT set as default (no false-verified bank)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ responses = [HOST_ROW, { rows: [{ id: 77 }] }]; calls = []; // requireHost, then the INSERT
+ plaid.exchangePublicToken = async () => ({ access_token: 'access-x', item_id: 'item-x' });
+ plaid.getAuth = async () => { throw new Error('plaid /auth/get HTTP 500'); }; // account details unavailable
+ const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-ok' }, token);
+ assert.equal(r.status, 200, 'the linked item is still recorded');
+ assert.equal(r.json.verified, false, 'a method with no account details is NOT verified');
+ const ins = findCall(/INSERT INTO payout_methods/);
+ assert.ok(ins, 'the payout method was inserted');
+ assert.equal(ins.args[5], false, 'verified param is false (VALUES ...$6,$6 -> verified + is_default both false)');
+ assert.equal(hasSql(/UPDATE hosts SET default_payout_method_id/), false, 'an unverified method is NEVER made the host default');
+});
+
+test('POST /host/plaid/exchange: getAuth success -> verified=TRUE and set as host default', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ responses = [HOST_ROW, { rows: [{ id: 88 }] }, { rowCount: 1 }]; calls = []; // requireHost, INSERT, UPDATE hosts
+ plaid.exchangePublicToken = async () => ({ access_token: 'access-y', item_id: 'item-y' });
+ plaid.getAuth = async () => ({ accounts: [{ account_id: 'acc_real', mask: '6789' }] });
+ const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-ok' }, token);
+ assert.equal(r.status, 200);
+ assert.equal(r.json.verified, true);
+ const ins = findCall(/INSERT INTO payout_methods/);
+ assert.equal(ins.args[5], true, 'verified param is true');
+ assert.ok(hasSql(/UPDATE hosts SET default_payout_method_id/), 'a verified method becomes the host default');
+});
+
+test('POST /host/plaid/exchange: a DB throw on the INSERT becomes a 502 (not an uncaught process crash)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ responses = [HOST_ROW, new Error('db connection reset')]; calls = []; // requireHost ok, INSERT throws
+ plaid.exchangePublicToken = async () => ({ access_token: 'access-z', item_id: 'item-z' });
+ plaid.getAuth = async () => ({ accounts: [{ account_id: 'acc_z', mask: '0000' }] });
+ const r = await post('/api/app/host/plaid/exchange', { public_token: 'public-ok' }, token);
+ assert.equal(r.status, 502, 'a DB failure while persisting is caught -> 502, never an unhandled rejection');
+});
← 0e2af37 cycle 9 docs: YOLO_NOTES ledger — admin.js audit, TOCTOU rac
·
back to Costa Rica
·
cycle 10 docs: YOLO_NOTES ledger — plaid.js fetch bound + un 5c5fc34 →