From 2a8ef45b000faed28bae43edfe5dc749a07b9374 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:23:47 -0600 Subject: [PATCH 1/3] feat: add Fastify global setErrorHandler plugin Register a Fastify plugin that catches unhandled route errors and returns structured JSON responses. PpgErrors map to their code/message, validation errors return VALIDATION_ERROR, and unexpected errors return INTERNAL_ERROR without leaking stack traces in production. Closes #87 --- package-lock.json | 625 +++++++++++++++++++++++++++++++ package.json | 2 + src/server/error-handler.test.ts | 144 +++++++ src/server/error-handler.ts | 56 +++ 4 files changed, 827 insertions(+) create mode 100644 src/server/error-handler.test.ts create mode 100644 src/server/error-handler.ts diff --git a/package-lock.json b/package-lock.json index a036a8f..d28582d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", + "fastify-plugin": "^5.1.0", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", @@ -474,6 +476,117 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -513,6 +626,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", @@ -1048,6 +1167,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1061,6 +1186,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1078,6 +1236,35 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1173,6 +1360,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1227,6 +1427,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1322,6 +1531,125 @@ "node": ">=12.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1355,6 +1683,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1435,6 +1777,15 @@ "node": ">=0.8.19" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1494,6 +1845,68 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1638,6 +2051,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1696,6 +2118,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1824,6 +2283,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1841,6 +2316,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1855,6 +2336,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1875,6 +2374,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -1884,6 +2392,22 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1929,6 +2453,68 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1969,6 +2555,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1989,6 +2584,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2084,6 +2688,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2145,6 +2761,15 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..93a928a 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", + "fastify-plugin": "^5.1.0", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", diff --git a/src/server/error-handler.test.ts b/src/server/error-handler.test.ts new file mode 100644 index 0000000..313530f --- /dev/null +++ b/src/server/error-handler.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import errorHandlerPlugin, { type ErrorResponseBody } from './error-handler.js'; +import { PpgError } from '../lib/errors.js'; + +describe('errorHandlerPlugin', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify({ logger: false }); + await app.register(errorHandlerPlugin); + }); + + afterEach(async () => { + await app.close(); + }); + + test('given a PpgError, should return its code and message', async () => { + app.get('/ppg-error', () => { + throw new PpgError('Worktree not found: wt-abc123', 'WORKTREE_NOT_FOUND'); + }); + + const response = await app.inject({ method: 'GET', url: '/ppg-error' }); + const body = response.json(); + + expect(response.statusCode).toBe(400); + expect(body.error.code).toBe('WORKTREE_NOT_FOUND'); + expect(body.error.message).toBe('Worktree not found: wt-abc123'); + }); + + test('given a PpgError with exitCode >= 400, should use exitCode as status', async () => { + app.get('/ppg-404', () => { + throw new PpgError('Not found', 'AGENT_NOT_FOUND', 404); + }); + + const response = await app.inject({ method: 'GET', url: '/ppg-404' }); + + expect(response.statusCode).toBe(404); + expect(response.json().error.code).toBe('AGENT_NOT_FOUND'); + }); + + test('given a PpgError with exitCode < 400, should default to 400', async () => { + app.get('/ppg-low-exit', () => { + throw new PpgError('Bad exit', 'SOME_ERROR', 1); + }); + + const response = await app.inject({ method: 'GET', url: '/ppg-low-exit' }); + + expect(response.statusCode).toBe(400); + }); + + test('given a validation error, should return 400 with VALIDATION_ERROR code', async () => { + app.get('/validation', () => { + const err = new Error('body/name must be string') as Error & { + validation: unknown; + statusCode: number; + }; + err.validation = [{ message: 'must be string' }]; + err.statusCode = 400; + throw err; + }); + + const response = await app.inject({ method: 'GET', url: '/validation' }); + const body = response.json(); + + expect(response.statusCode).toBe(400); + expect(body.error.code).toBe('VALIDATION_ERROR'); + expect(body.error.message).toBe('body/name must be string'); + }); + + test('given an unhandled error in production, should not leak details', async () => { + const original = process.env['NODE_ENV']; + process.env['NODE_ENV'] = 'production'; + + app.get('/unexpected', () => { + throw new Error('secret database connection string leaked'); + }); + + const response = await app.inject({ method: 'GET', url: '/unexpected' }); + const body = response.json(); + + expect(response.statusCode).toBe(500); + expect(body.error.code).toBe('INTERNAL_ERROR'); + expect(body.error.message).toBe('Internal server error'); + expect(JSON.stringify(body)).not.toContain('secret'); + + process.env['NODE_ENV'] = original; + }); + + test('given an unhandled error in development, should include error message', async () => { + const original = process.env['NODE_ENV']; + delete process.env['NODE_ENV']; + + app.get('/dev-error', () => { + throw new Error('something broke'); + }); + + const response = await app.inject({ method: 'GET', url: '/dev-error' }); + const body = response.json(); + + expect(response.statusCode).toBe(500); + expect(body.error.code).toBe('INTERNAL_ERROR'); + expect(body.error.message).toBe('something broke'); + + process.env['NODE_ENV'] = original; + }); + + test('given an unhandled error with statusCode, should preserve it', async () => { + app.get('/custom-status', () => { + const err = new Error('service unavailable') as Error & { + statusCode: number; + }; + err.statusCode = 503; + throw err; + }); + + const response = await app.inject({ method: 'GET', url: '/custom-status' }); + + expect(response.statusCode).toBe(503); + expect(response.json().error.code).toBe('INTERNAL_ERROR'); + }); + + test('given an error with statusCode < 400, should default to 500', async () => { + app.get('/low-status', () => { + const err = new Error('weird status') as Error & { statusCode: number }; + err.statusCode = 200; + throw err; + }); + + const response = await app.inject({ method: 'GET', url: '/low-status' }); + + expect(response.statusCode).toBe(500); + }); + + test('should return valid JSON content-type', async () => { + app.get('/json-check', () => { + throw new Error('test'); + }); + + const response = await app.inject({ method: 'GET', url: '/json-check' }); + + expect(response.headers['content-type']).toContain('application/json'); + }); +}); diff --git a/src/server/error-handler.ts b/src/server/error-handler.ts new file mode 100644 index 0000000..b72fdeb --- /dev/null +++ b/src/server/error-handler.ts @@ -0,0 +1,56 @@ +import fp from 'fastify-plugin'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { PpgError } from '../lib/errors.js'; + +export interface ErrorResponseBody { + error: { + code: string; + message: string; + }; +} + +function errorHandler( + error: Error & { statusCode?: number; validation?: unknown }, + request: FastifyRequest, + reply: FastifyReply, +): void { + if (error instanceof PpgError) { + request.log.warn({ err: error, code: error.code }, error.message); + reply.status(error.exitCode >= 400 ? error.exitCode : 400).send({ + error: { code: error.code, message: error.message }, + } satisfies ErrorResponseBody); + return; + } + + // Fastify validation errors (e.g. schema validation) + if (error.validation) { + request.log.warn({ err: error }, 'Validation error'); + reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: error.message }, + } satisfies ErrorResponseBody); + return; + } + + // Unexpected errors — log full details, return safe response + request.log.error(error, 'Unhandled error'); + const statusCode = + error.statusCode && error.statusCode >= 400 ? error.statusCode : 500; + reply.status(statusCode).send({ + error: { + code: 'INTERNAL_ERROR', + message: + process.env['NODE_ENV'] === 'production' + ? 'Internal server error' + : error.message, + }, + } satisfies ErrorResponseBody); +} + +export default fp( + async function errorHandlerPlugin(fastify: FastifyInstance) { + fastify.setErrorHandler(errorHandler); + }, + { name: 'ppg-error-handler' }, +); + +export { errorHandler }; From f250caa94548f1cc9c10ddeec1cf58eeb8c0addd Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:48:21 -0600 Subject: [PATCH 2/3] fix: address code review findings for error handler plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: Replace exitCode-as-HTTP-status with explicit code-to-status lookup map (httpStatusByCode). PpgError.exitCode is a CLI concept, not an HTTP one — WORKTREE_NOT_FOUND→404, MANIFEST_LOCK→409, etc. - P2: Remove unused vi import, use vi.stubEnv for safe env mutation in tests with vi.unstubAllEnvs in afterEach - P2: Add await app.ready() before inject in all tests - P3: Fix test naming to follow 'given X, should Y' convention - P3: Add async route handler test case - Add httpStatusForPpgError unit tests for lookup table coverage --- src/server/error-handler.test.ts | 81 +++++++++++++++++++++++++------- src/server/error-handler.ts | 28 ++++++++++- 2 files changed, 89 insertions(+), 20 deletions(-) diff --git a/src/server/error-handler.test.ts b/src/server/error-handler.test.ts index 313530f..ffc60d5 100644 --- a/src/server/error-handler.test.ts +++ b/src/server/error-handler.test.ts @@ -1,6 +1,9 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; -import errorHandlerPlugin, { type ErrorResponseBody } from './error-handler.js'; +import errorHandlerPlugin, { + httpStatusForPpgError, + type ErrorResponseBody, +} from './error-handler.js'; import { PpgError } from '../lib/errors.js'; describe('errorHandlerPlugin', () => { @@ -12,26 +15,29 @@ describe('errorHandlerPlugin', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); await app.close(); }); - test('given a PpgError, should return its code and message', async () => { + test('given a WORKTREE_NOT_FOUND PpgError, should return 404 with its code', async () => { app.get('/ppg-error', () => { throw new PpgError('Worktree not found: wt-abc123', 'WORKTREE_NOT_FOUND'); }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/ppg-error' }); const body = response.json(); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(404); expect(body.error.code).toBe('WORKTREE_NOT_FOUND'); expect(body.error.message).toBe('Worktree not found: wt-abc123'); }); - test('given a PpgError with exitCode >= 400, should use exitCode as status', async () => { + test('given an AGENT_NOT_FOUND PpgError, should return 404', async () => { app.get('/ppg-404', () => { - throw new PpgError('Not found', 'AGENT_NOT_FOUND', 404); + throw new PpgError('Agent not found: ag-abc', 'AGENT_NOT_FOUND'); }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/ppg-404' }); @@ -39,12 +45,24 @@ describe('errorHandlerPlugin', () => { expect(response.json().error.code).toBe('AGENT_NOT_FOUND'); }); - test('given a PpgError with exitCode < 400, should default to 400', async () => { - app.get('/ppg-low-exit', () => { - throw new PpgError('Bad exit', 'SOME_ERROR', 1); + test('given a MANIFEST_LOCK PpgError, should return 409', async () => { + app.get('/ppg-lock', () => { + throw new PpgError('Could not acquire lock', 'MANIFEST_LOCK'); }); + await app.ready(); + + const response = await app.inject({ method: 'GET', url: '/ppg-lock' }); - const response = await app.inject({ method: 'GET', url: '/ppg-low-exit' }); + expect(response.statusCode).toBe(409); + }); + + test('given an unknown PpgError code, should default to 400', async () => { + app.get('/ppg-unknown', () => { + throw new PpgError('Something odd', 'UNKNOWN_CODE'); + }); + await app.ready(); + + const response = await app.inject({ method: 'GET', url: '/ppg-unknown' }); expect(response.statusCode).toBe(400); }); @@ -59,6 +77,7 @@ describe('errorHandlerPlugin', () => { err.statusCode = 400; throw err; }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/validation' }); const body = response.json(); @@ -69,12 +88,12 @@ describe('errorHandlerPlugin', () => { }); test('given an unhandled error in production, should not leak details', async () => { - const original = process.env['NODE_ENV']; - process.env['NODE_ENV'] = 'production'; + vi.stubEnv('NODE_ENV', 'production'); app.get('/unexpected', () => { throw new Error('secret database connection string leaked'); }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/unexpected' }); const body = response.json(); @@ -83,17 +102,15 @@ describe('errorHandlerPlugin', () => { expect(body.error.code).toBe('INTERNAL_ERROR'); expect(body.error.message).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('secret'); - - process.env['NODE_ENV'] = original; }); test('given an unhandled error in development, should include error message', async () => { - const original = process.env['NODE_ENV']; - delete process.env['NODE_ENV']; + vi.stubEnv('NODE_ENV', 'development'); app.get('/dev-error', () => { throw new Error('something broke'); }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/dev-error' }); const body = response.json(); @@ -101,8 +118,6 @@ describe('errorHandlerPlugin', () => { expect(response.statusCode).toBe(500); expect(body.error.code).toBe('INTERNAL_ERROR'); expect(body.error.message).toBe('something broke'); - - process.env['NODE_ENV'] = original; }); test('given an unhandled error with statusCode, should preserve it', async () => { @@ -113,6 +128,7 @@ describe('errorHandlerPlugin', () => { err.statusCode = 503; throw err; }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/custom-status' }); @@ -126,19 +142,48 @@ describe('errorHandlerPlugin', () => { err.statusCode = 200; throw err; }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/low-status' }); expect(response.statusCode).toBe(500); }); - test('should return valid JSON content-type', async () => { + test('given an error response, should return application/json content-type', async () => { app.get('/json-check', () => { throw new Error('test'); }); + await app.ready(); const response = await app.inject({ method: 'GET', url: '/json-check' }); expect(response.headers['content-type']).toContain('application/json'); }); + + test('given an async route that rejects, should catch and format the error', async () => { + app.get('/async-error', async () => { + throw new PpgError('Not initialized', 'NOT_INITIALIZED'); + }); + await app.ready(); + + const response = await app.inject({ method: 'GET', url: '/async-error' }); + const body = response.json(); + + expect(response.statusCode).toBe(400); + expect(body.error.code).toBe('NOT_INITIALIZED'); + }); +}); + +describe('httpStatusForPpgError', () => { + test('given a known code, should return the mapped HTTP status', () => { + expect(httpStatusForPpgError('WORKTREE_NOT_FOUND')).toBe(404); + expect(httpStatusForPpgError('AGENT_NOT_FOUND')).toBe(404); + expect(httpStatusForPpgError('MANIFEST_LOCK')).toBe(409); + expect(httpStatusForPpgError('WAIT_TIMEOUT')).toBe(504); + expect(httpStatusForPpgError('TMUX_NOT_FOUND')).toBe(500); + }); + + test('given an unknown code, should return 400', () => { + expect(httpStatusForPpgError('MADE_UP_CODE')).toBe(400); + }); }); diff --git a/src/server/error-handler.ts b/src/server/error-handler.ts index b72fdeb..80a54b4 100644 --- a/src/server/error-handler.ts +++ b/src/server/error-handler.ts @@ -9,14 +9,38 @@ export interface ErrorResponseBody { }; } +const httpStatusByCode: Record = { + WORKTREE_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, + NOT_INITIALIZED: 400, + NOT_GIT_REPO: 400, + INVALID_ARGS: 400, + MANIFEST_LOCK: 409, + MERGE_FAILED: 409, + AGENTS_RUNNING: 409, + TMUX_NOT_FOUND: 500, + GH_NOT_FOUND: 500, + UNMERGED_WORK: 409, + WAIT_TIMEOUT: 504, + AGENTS_FAILED: 502, + NO_SESSION_ID: 400, +}; + +const defaultPpgStatus = 400; + +function httpStatusForPpgError(code: string): number { + return httpStatusByCode[code] ?? defaultPpgStatus; +} + function errorHandler( error: Error & { statusCode?: number; validation?: unknown }, request: FastifyRequest, reply: FastifyReply, ): void { if (error instanceof PpgError) { + const status = httpStatusForPpgError(error.code); request.log.warn({ err: error, code: error.code }, error.message); - reply.status(error.exitCode >= 400 ? error.exitCode : 400).send({ + reply.status(status).send({ error: { code: error.code, message: error.message }, } satisfies ErrorResponseBody); return; @@ -53,4 +77,4 @@ export default fp( { name: 'ppg-error-handler' }, ); -export { errorHandler }; +export { errorHandler, httpStatusForPpgError }; From dae3278fa60f9d7b25c40138f5144b9b1f718948 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:37:38 -0600 Subject: [PATCH 3/3] Fix spawn test manifest typing for typecheck --- src/commands/spawn.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..c3faa2d 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -137,7 +138,7 @@ describe('spawnCommand', () => { mockedResolveWorktree.mockImplementation((manifest, ref) => (manifest as any).worktrees[ref as string]); mockedUpdateManifest.mockImplementation(async (_projectRoot, updater) => { manifestState = await updater(structuredClone(manifestState)); - return manifestState as any; + return manifestState; }); mockedAgentId.mockImplementation(() => `ag-${nextAgent++}`); mockedSessionId.mockImplementation(() => `session-${nextSession++}`);