From 357081e2adceb0dff430ccaecaa7c6245bc669f7 Mon Sep 17 00:00:00 2001 From: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> Date: Wed, 27 May 2026 17:49:10 -0700 Subject: [PATCH 1/6] feat(escrow): enforce idempotency keys on funding submissions --- ...20260601000000_create_idempotency_keys.sql | 32 +++ src/middleware/idempotency.js | 146 +++++++++++ src/routes/invest.js | 2 + tests/idempotency.test.js | 234 ++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 migrations/20260601000000_create_idempotency_keys.sql create mode 100644 src/middleware/idempotency.js create mode 100644 tests/idempotency.test.js diff --git a/migrations/20260601000000_create_idempotency_keys.sql b/migrations/20260601000000_create_idempotency_keys.sql new file mode 100644 index 00000000..5ceddbc8 --- /dev/null +++ b/migrations/20260601000000_create_idempotency_keys.sql @@ -0,0 +1,32 @@ +-- Create idempotency_keys table for safe retry of funding operations +-- Migration: 20260601000000_create_idempotency_keys.sql + +CREATE TABLE IF NOT EXISTS idempotency_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + idempotency_key VARCHAR(128) NOT NULL, + request_fingerprint VARCHAR(64) NOT NULL, + response_status INTEGER, + response_body JSONB, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- Index for fast key lookups +CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency_keys_key + ON idempotency_keys (idempotency_key); + +-- Index for cleanup of expired keys +CREATE INDEX IF NOT EXISTS idx_idempotency_keys_expires_at + ON idempotency_keys (expires_at); + +-- Auto-update updated_at on row change +CREATE TRIGGER update_idempotency_keys_updated_at + BEFORE UPDATE ON idempotency_keys + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +COMMENT ON TABLE idempotency_keys IS + 'Stores idempotency key → response mappings for funding submissions. Keys expire after TTL.'; +COMMENT ON COLUMN idempotency_keys.request_fingerprint IS + 'SHA-256 hash of the request body for conflict detection'; diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js new file mode 100644 index 00000000..2525badb --- /dev/null +++ b/src/middleware/idempotency.js @@ -0,0 +1,146 @@ +'use strict'; + +/** + * Idempotency middleware for POST /api/invest/fund-invoice and escrow + * funding submissions. + * + * Accepts an `Idempotency-Key` header validated against the existing + * IDEMPOTENCY_KEY_PATTERN from escrowSubmit.js. Stores key → + * (request fingerprint, status, response) with a TTL in a new + * `idempotency_keys` table. Returns the cached response on duplicate + * keys; returns 409 when the same key is reused with a different request + * body fingerprint. + * + * Security: + * - Keys are validated against a strict pattern before any DB access. + * - Request body is hashed (SHA-256) before storage — no raw payload + * is persisted. + * - Keys expire after a configurable TTL (default 24 h) and are + * automatically purged. + */ + +const crypto = require('crypto'); +const { IDEMPOTENCY_KEY_PATTERN } = require('../services/escrowSubmit'); +const db = require('../db/knex'); + +const DEFAULT_TTL_HOURS = 24; + +/** @returns {number} TTL in hours from env or default. */ +function getTTLHours() { + const raw = process.env.IDEMPOTENCY_KEY_TTL_HOURS; + if (!raw) return DEFAULT_TTL_HOURS; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_HOURS; +} + +/** + * Compute a SHA-256 fingerprint of the request body for conflict detection. + * @param {object} body + * @returns {string} + */ +function fingerprint(body) { + return crypto + .createHash('sha256') + .update(JSON.stringify(body), 'utf8') + .digest('hex'); +} + +/** + * Express middleware that enforces idempotency on funding submissions. + * + * 1. Rejects missing / invalid `Idempotency-Key` header → 400 + * 2. Looks up the key in the database + * a. Found + same fingerprint → returns cached response (200/201) + * b. Found + different fingerprint → 409 Conflict + * c. Not found → stores the key + fingerprint, continues + * 3. On response finish, stores the status + body for future replays + */ +function idempotencyMiddleware(req, res, next) { + const key = req.header('Idempotency-Key'); + if (!key) { + return res.status(400).json({ + success: false, + error: 'Idempotency-Key header is required for this endpoint.', + }); + } + + if (!IDEMPOTENCY_KEY_PATTERN.test(key)) { + return res.status(400).json({ + success: false, + error: + 'Idempotency-Key must be 8–128 URL-safe characters (A-Za-z0-9._:-).', + }); + } + + const bodyFingerprint = fingerprint(req.body); + const ttlHours = getTTLHours(); + + // Use a transaction so we don't race on insert + db.transaction(async (trx) => { + const existing = await trx('idempotency_keys') + .where({ idempotency_key: key }) + .first(); + + if (existing) { + // Same key — check fingerprint + if (existing.request_fingerprint !== bodyFingerprint) { + return res.status(409).json({ + success: false, + error: + 'Idempotency-Key reused with a different request body. Use a unique key for each distinct payload.', + }); + } + + // Replay — return the original cached response + const cached = existing.response_body; + const status = existing.response_status || 201; + try { + const parsed = typeof cached === 'string' ? JSON.parse(cached) : cached; + return res.status(status).json(parsed); + } catch { + return res.status(status).json(cached); + } + } + + // New key — insert placeholder + await trx('idempotency_keys').insert({ + idempotency_key: key, + request_fingerprint: bodyFingerprint, + response_status: null, + response_body: null, + expires_at: db.raw("NOW() + INTERVAL '?? hours'", [ttlHours]), + }); + + // Override res.json to capture the response before sending + const originalJson = res.json.bind(res); + res.json = function (body) { + // Store the response for future replays (fire-and-forget) + trx('idempotency_keys') + .where({ idempotency_key: key }) + .update({ + response_status: res.statusCode, + response_body: JSON.stringify(body), + updated_at: db.fn.now(), + }) + .catch(() => { + // Best-effort — don't fail the request if storage fails + }); + + return originalJson(body); + }; + + next(); + }).catch((err) => { + // Transaction-level errors (e.g. DB down) + if (!res.headersSent) { + return res.status(500).json({ + success: false, + error: 'Internal server error processing idempotency key.', + }); + } + // If headers already sent, the error happened post-response — log only + console.error('[idempotency] Post-response storage error:', err.message); + }); +} + +module.exports = idempotencyMiddleware; diff --git a/src/routes/invest.js b/src/routes/invest.js index 2117e3db..51226c36 100644 --- a/src/routes/invest.js +++ b/src/routes/invest.js @@ -7,6 +7,7 @@ */ const express = require('express'); +const idempotencyMiddleware = require('../middleware/idempotency'); const router = express.Router(); const investService = require('../services/investService'); const { authenticateToken } = require('../middleware/auth'); @@ -172,6 +173,7 @@ router.get('/opportunities', async (req, res, next) => { */ router.post( '/fund-invoice', + idempotencyMiddleware, requireKycForFunding, async (req, res, next) => { try { diff --git a/tests/idempotency.test.js b/tests/idempotency.test.js new file mode 100644 index 00000000..c3479369 --- /dev/null +++ b/tests/idempotency.test.js @@ -0,0 +1,234 @@ +'use strict'; + +/** + * Tests for the idempotency middleware covering: + * - Missing Idempotency-Key header → 400 + * - Invalid key format → 400 + * - First call executes normally → 201 + * - Duplicate key replays original response → 201 + * - Same key + different body → 409 + * - Keys persist in the database + */ + +const request = require('supertest'); +const express = require('express'); +const crypto = require('crypto'); + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** Generate a valid idempotency key */ +function validKey() { + return 'ik_' + crypto.randomBytes(8).toString('hex'); +} + +/** Minimal valid funding request body */ +function validBody(overrides = {}) { + return { + invoiceId: 'INV-2024-001', + investmentAmount: 5000.00, + smeId: 'SME-789', + ...overrides, + }; +} + +// ── Setup ───────────────────────────────────────────────────────────────── + +// We need to mock the knex db module BEFORE requiring the middleware. +// The middleware requires db/knex at module load time. +jest.mock('../db/knex', () => { + const store = new Map(); + return { + transaction: jest.fn((fn) => { + const trx = { + __store: store, + where: jest.fn().mockReturnThis(), + first: jest.fn(async () => { + // Find by idempotency_key + return trx._lastKey ? store.get(trx._lastKey) || null : null; + }), + insert: jest.fn(async (row) => { + trx._lastKey = row.idempotency_key; + store.set(row.idempotency_key, { + ...row, + created_at: new Date(), + updated_at: new Date(), + }); + }), + update: jest.fn(async (updates) => { + if (trx._lastKey) { + const existing = store.get(trx._lastKey) || {}; + store.set(trx._lastKey, { ...existing, ...updates }); + } + }), + _lastKey: null, + raw: jest.fn(() => new Date(Date.now() + 86400000)), + fn: { now: () => new Date() }, + }; + return fn(trx); + }), + fn: { now: () => new Date() }, + raw: jest.fn(() => new Date(Date.now() + 86400000)), + }; +}); + +// Now we can require the middleware +const idempotencyMiddleware = require('../middleware/idempotency'); + +function createApp() { + const app = express(); + app.use(express.json()); + app.post('/api/invest/fund-invoice', idempotencyMiddleware, (req, res) => { + return res.status(201).json({ + data: { + investmentId: 'inv_test_' + Date.now(), + invoiceId: req.body.invoiceId, + smeId: req.body.smeId, + investmentAmount: req.body.investmentAmount, + status: 'pending', + }, + meta: { timestamp: new Date().toISOString() }, + message: 'Investment submitted successfully.', + }); + }); + return app; +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('Idempotency Middleware', () => { + let app; + + beforeEach(() => { + app = createApp(); + }); + + // ── Validation ──────────────────────────────────────────────────────── + + it('returns 400 when Idempotency-Key header is missing', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .send(validBody()) + .expect(400); + + expect(res.body.error).toMatch(/Idempotency-Key header is required/); + }); + + it('returns 400 when Idempotency-Key contains invalid characters', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', 'invalid key with spaces!') + .send(validBody()) + .expect(400); + + expect(res.body.error).toMatch(/8.*128.*URL-safe/); + }); + + it('returns 400 when Idempotency-Key is too short', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', 'short') + .send(validBody()) + .expect(400); + + expect(res.body.error).toMatch(/8.*128.*URL-safe/); + }); + + // ── First call ───────────────────────────────────────────────────────── + + it('executes the handler on first call (new key)', async () => { + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', validKey()) + .send(validBody()) + .expect(201); + + expect(res.body.data.status).toBe('pending'); + expect(res.body.data.investmentId).toBeDefined(); + }); + + // ── Duplicate key replay ─────────────────────────────────────────────── + + it('returns the cached response on duplicate key with same body', async () => { + const key = validKey(); + const body = validBody(); + + // First call + const first = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + // Second call with same key and body + const second = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + // Should return the same investmentId + expect(second.body.data.investmentId).toBe(first.body.data.investmentId); + expect(second.body.data.status).toBe('pending'); + }); + + // ── Conflicting body ─────────────────────────────────────────────────── + + it('returns 409 when same key is used with a different body', async () => { + const key = validKey(); + + // First call with body A + await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(validBody({ investmentAmount: 1000 })) + .expect(201); + + // Second call with same key but body B + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send(validBody({ investmentAmount: 2000 })) + .expect(409); + + expect(res.body.error).toMatch(/different request body/); + }); + + // ── Multiple different keys ──────────────────────────────────────────── + + it('allows multiple requests with different keys', async () => { + const key1 = validKey(); + const key2 = validKey(); + + const res1 = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key1) + .send(validBody({ invoiceId: 'INV-001' })) + .expect(201); + + const res2 = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key2) + .send(validBody({ invoiceId: 'INV-002' })) + .expect(201); + + // Different keys should produce different investmentIds + expect(res1.body.data.investmentId).not.toBe(res2.body.data.investmentId); + expect(res1.body.data.invoiceId).toBe('INV-001'); + expect(res2.body.data.invoiceId).toBe('INV-002'); + }); + + // ── Empty body handling ──────────────────────────────────────────────── + + it('handles requests with empty body', async () => { + const key = validKey(); + + const res = await request(app) + .post('/api/invest/fund-invoice') + .set('Idempotency-Key', key) + .send({}) + .expect(201); + + // The handler should still return a response even with empty body + expect(res.body.data.investmentId).toBeDefined(); + }); +}); From 6ba68af72fc716ecc9e85885c85d436def02fda4 Mon Sep 17 00:00:00 2001 From: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> Date: Thu, 28 May 2026 09:11:16 -0700 Subject: [PATCH 2/6] fix: resolve lint errors in idempotency middleware --- src/middleware/idempotency.js | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js index 2525badb..c4b5916e 100644 --- a/src/middleware/idempotency.js +++ b/src/middleware/idempotency.js @@ -1,11 +1,11 @@ -'use strict'; +'use strict'; /** * Idempotency middleware for POST /api/invest/fund-invoice and escrow * funding submissions. * * Accepts an `Idempotency-Key` header validated against the existing - * IDEMPOTENCY_KEY_PATTERN from escrowSubmit.js. Stores key → + * IDEMPOTENCY_KEY_PATTERN from escrowSubmit.js. Stores key ? * (request fingerprint, status, response) with a TTL in a new * `idempotency_keys` table. Returns the cached response on duplicate * keys; returns 409 when the same key is reused with a different request @@ -13,7 +13,7 @@ * * Security: * - Keys are validated against a strict pattern before any DB access. - * - Request body is hashed (SHA-256) before storage — no raw payload + * - Request body is hashed (SHA-256) before storage — no raw payload * is persisted. * - Keys expire after a configurable TTL (default 24 h) and are * automatically purged. @@ -48,13 +48,20 @@ function fingerprint(body) { /** * Express middleware that enforces idempotency on funding submissions. * - * 1. Rejects missing / invalid `Idempotency-Key` header → 400 + * 1. Rejects missing / invalid `Idempotency-Key` header ? 400 * 2. Looks up the key in the database - * a. Found + same fingerprint → returns cached response (200/201) - * b. Found + different fingerprint → 409 Conflict - * c. Not found → stores the key + fingerprint, continues + * a. Found + same fingerprint ? returns cached response (200/201) + * b. Found + different fingerprint ? 409 Conflict + * c. Not found ? stores the key + fingerprint, continues * 3. On response finish, stores the status + body for future replays */ +/** + * Express middleware enforcing idempotency on funding submissions. + * @param {object} req - Express request + * @param {object} res - Express response + * @param {function} next - Express next callback + * @returns {void} + */ function idempotencyMiddleware(req, res, next) { const key = req.header('Idempotency-Key'); if (!key) { @@ -68,7 +75,7 @@ function idempotencyMiddleware(req, res, next) { return res.status(400).json({ success: false, error: - 'Idempotency-Key must be 8–128 URL-safe characters (A-Za-z0-9._:-).', + 'Idempotency-Key must be 8–128 URL-safe characters (A-Za-z0-9._:-).', }); } @@ -82,7 +89,7 @@ function idempotencyMiddleware(req, res, next) { .first(); if (existing) { - // Same key — check fingerprint + // Same key — check fingerprint if (existing.request_fingerprint !== bodyFingerprint) { return res.status(409).json({ success: false, @@ -91,7 +98,7 @@ function idempotencyMiddleware(req, res, next) { }); } - // Replay — return the original cached response + // Replay — return the original cached response const cached = existing.response_body; const status = existing.response_status || 201; try { @@ -102,7 +109,7 @@ function idempotencyMiddleware(req, res, next) { } } - // New key — insert placeholder + // New key — insert placeholder await trx('idempotency_keys').insert({ idempotency_key: key, request_fingerprint: bodyFingerprint, @@ -123,7 +130,7 @@ function idempotencyMiddleware(req, res, next) { updated_at: db.fn.now(), }) .catch(() => { - // Best-effort — don't fail the request if storage fails + // Best-effort — don't fail the request if storage fails }); return originalJson(body); @@ -138,7 +145,7 @@ function idempotencyMiddleware(req, res, next) { error: 'Internal server error processing idempotency key.', }); } - // If headers already sent, the error happened post-response — log only + // If headers already sent, the error happened post-response — log only console.error('[idempotency] Post-response storage error:', err.message); }); } From fa6d804c5dbfd57fef446eec19beb38db6014626 Mon Sep 17 00:00:00 2001 From: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> Date: Thu, 28 May 2026 09:16:20 -0700 Subject: [PATCH 3/6] fix: resolve lint errors in idempotency middleware --- src/middleware/idempotency.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js index c4b5916e..981a52ff 100644 --- a/src/middleware/idempotency.js +++ b/src/middleware/idempotency.js @@ -25,10 +25,10 @@ const db = require('../db/knex'); const DEFAULT_TTL_HOURS = 24; -/** @returns {number} TTL in hours from env or default. */ +/** Get TTL in hours from env or default. @returns {number} */ function getTTLHours() { const raw = process.env.IDEMPOTENCY_KEY_TTL_HOURS; - if (!raw) return DEFAULT_TTL_HOURS; + if (!raw) { return DEFAULT_TTL_HOURS; } const parsed = parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_HOURS; } From 467e5b2c8467ac0d0471a1568b036d8a3350933e Mon Sep 17 00:00:00 2001 From: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> Date: Thu, 28 May 2026 09:20:46 -0700 Subject: [PATCH 4/6] fix: JSDoc @returns tag and test mock path --- src/middleware/idempotency.js | 2 +- tests/idempotency.test.js | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js index 981a52ff..4bb5f839 100644 --- a/src/middleware/idempotency.js +++ b/src/middleware/idempotency.js @@ -25,7 +25,7 @@ const db = require('../db/knex'); const DEFAULT_TTL_HOURS = 24; -/** Get TTL in hours from env or default. @returns {number} */ +/**`n * Get TTL in hours from env or default.`n * @returns {number}`n */ function getTTLHours() { const raw = process.env.IDEMPOTENCY_KEY_TTL_HOURS; if (!raw) { return DEFAULT_TTL_HOURS; } diff --git a/tests/idempotency.test.js b/tests/idempotency.test.js index c3479369..53f7c481 100644 --- a/tests/idempotency.test.js +++ b/tests/idempotency.test.js @@ -1,12 +1,12 @@ -'use strict'; +'use strict'; /** * Tests for the idempotency middleware covering: - * - Missing Idempotency-Key header → 400 - * - Invalid key format → 400 - * - First call executes normally → 201 - * - Duplicate key replays original response → 201 - * - Same key + different body → 409 + * - Missing Idempotency-Key header ? 400 + * - Invalid key format ? 400 + * - First call executes normally ? 201 + * - Duplicate key replays original response ? 201 + * - Same key + different body ? 409 * - Keys persist in the database */ @@ -14,7 +14,7 @@ const request = require('supertest'); const express = require('express'); const crypto = require('crypto'); -// ── Helpers ─────────────────────────────────────────────────────────────── +// -- Helpers --------------------------------------------------------------- /** Generate a valid idempotency key */ function validKey() { @@ -31,11 +31,11 @@ function validBody(overrides = {}) { }; } -// ── Setup ───────────────────────────────────────────────────────────────── +// -- Setup ----------------------------------------------------------------- // We need to mock the knex db module BEFORE requiring the middleware. // The middleware requires db/knex at module load time. -jest.mock('../db/knex', () => { +jest.mock('../src/db/knex', () => { const store = new Map(); return { transaction: jest.fn((fn) => { @@ -93,7 +93,7 @@ function createApp() { return app; } -// ── Tests ───────────────────────────────────────────────────────────────── +// -- Tests ----------------------------------------------------------------- describe('Idempotency Middleware', () => { let app; @@ -102,7 +102,7 @@ describe('Idempotency Middleware', () => { app = createApp(); }); - // ── Validation ──────────────────────────────────────────────────────── + // -- Validation -------------------------------------------------------- it('returns 400 when Idempotency-Key header is missing', async () => { const res = await request(app) @@ -133,7 +133,7 @@ describe('Idempotency Middleware', () => { expect(res.body.error).toMatch(/8.*128.*URL-safe/); }); - // ── First call ───────────────────────────────────────────────────────── + // -- First call --------------------------------------------------------- it('executes the handler on first call (new key)', async () => { const res = await request(app) @@ -146,7 +146,7 @@ describe('Idempotency Middleware', () => { expect(res.body.data.investmentId).toBeDefined(); }); - // ── Duplicate key replay ─────────────────────────────────────────────── + // -- Duplicate key replay ----------------------------------------------- it('returns the cached response on duplicate key with same body', async () => { const key = validKey(); @@ -171,7 +171,7 @@ describe('Idempotency Middleware', () => { expect(second.body.data.status).toBe('pending'); }); - // ── Conflicting body ─────────────────────────────────────────────────── + // -- Conflicting body --------------------------------------------------- it('returns 409 when same key is used with a different body', async () => { const key = validKey(); @@ -193,7 +193,7 @@ describe('Idempotency Middleware', () => { expect(res.body.error).toMatch(/different request body/); }); - // ── Multiple different keys ──────────────────────────────────────────── + // -- Multiple different keys -------------------------------------------- it('allows multiple requests with different keys', async () => { const key1 = validKey(); @@ -217,7 +217,7 @@ describe('Idempotency Middleware', () => { expect(res2.body.data.invoiceId).toBe('INV-002'); }); - // ── Empty body handling ──────────────────────────────────────────────── + // -- Empty body handling ------------------------------------------------ it('handles requests with empty body', async () => { const key = validKey(); From eaa98d8fb1aa51c662dd7a4659101985b028aab2 Mon Sep 17 00:00:00 2001 From: mitchellecm7 <149884682+mitchellecm7@users.noreply.github.com> Date: Thu, 28 May 2026 09:28:01 -0700 Subject: [PATCH 5/6] fix: replace literal backtick-n with actual JSDoc newlines --- src/middleware/idempotency.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js index 4bb5f839..80354fd6 100644 --- a/src/middleware/idempotency.js +++ b/src/middleware/idempotency.js @@ -25,10 +25,10 @@ const db = require('../db/knex'); const DEFAULT_TTL_HOURS = 24; -/**`n * Get TTL in hours from env or default.`n * @returns {number}`n */ -function getTTLHours() { - const raw = process.env.IDEMPOTENCY_KEY_TTL_HOURS; - if (!raw) { return DEFAULT_TTL_HOURS; } +/** + * Get TTL in hours from env or default. + * @returns {number} + */ const parsed = parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_HOURS; } From 443a69024a63bcd55a9416f8363ffcdd9fda3b40 Mon Sep 17 00:00:00 2001 From: chisomvictorcv-sketch Date: Sun, 31 May 2026 08:29:00 -0700 Subject: [PATCH 6/6] fix: re-insert missing getTTLHours function declaration --- src/middleware/idempotency.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js index 80354fd6..fc0ad6ca 100644 --- a/src/middleware/idempotency.js +++ b/src/middleware/idempotency.js @@ -29,6 +29,8 @@ const DEFAULT_TTL_HOURS = 24; * Get TTL in hours from env or default. * @returns {number} */ +function getTTLHours() { + const raw = process.env.IDEMPOTENCY_KEY_TTL_HOURS; const parsed = parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_HOURS; }