hardening: Prisma error handling, rate limiting, request tracing, input normalization, and infra improvements#1
Merged
Conversation
…ll package in main worktree
There was a problem hiding this comment.
Pull request overview
This PR hardens the Standards API across the repository, HTTP/middleware, validation/service, and container build layers—adding Prisma error translation, request tracing, rate limiting, input normalization, and smaller Docker build context.
Changes:
- Translate key Prisma errors into domain errors (409 conflict / 404 not found) and stabilize ordering for rule-key lookups.
- Add request ID generation, structured rate-limit responses, and improved auth-failure logging for write endpoints.
- Normalize and validate
appliesToinputs (lowercasing selected fields; rejecting empty arrays), plus Docker build/pinning improvements.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/repositories/prisma-standards-repository.ts |
Adds Prisma error translation and explicit ordering to reduce undefined behavior. |
src/domain/errors.ts |
Introduces/ensures domain error types for conflict and not-found cases. |
src/app.ts |
Registers global rate limiting, configures request IDs, and adds request_id to most error responses. |
src/http/routes.ts |
Applies per-route rate limits to write endpoints and logs structured auth failures. |
src/services/standards-service.ts |
Normalizes appliesTo values by lowercasing selected metadata arrays. |
src/validation/standards.ts |
Rejects empty appliesTo arrays when provided to avoid “matches nothing” surprises. |
test/standards.test.ts |
Adds a concurrency test intended to validate duplicate-create behavior. |
Dockerfile |
Pins Node image tag and switches to Alpine across stages. |
.dockerignore |
Excludes dev/test artifacts to reduce build context and image bloat. |
package.json |
Adds @fastify/rate-limit. |
package-lock.json |
Locks @fastify/rate-limit dependency resolution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Remove dead P2025 catch from read methods (findMany/findFirst/findUnique return null/[] on miss, never throw P2025); keep P2025 only on update() and updateReplacingActive() where it can actually fire - Collapse P2002+P2025 to single-condition checks in create/createReplacingActive - Fix genReqId to normalize x-request-id header safely (handle string[] and reject values over 255 chars) instead of unsafe `as string` cast - Add request_id to rate-limit (429) error responses so clients can correlate them the same way as other errors - Add request_id to setNotFoundHandler (404) response - Fix MemoryStandardsRepository.create() to enforce (ruleKey, version) uniqueness atomically, making the concurrent-creation test deterministic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move duplicate check before mutation in MemoryStandardsRepository.createReplacingActive() so a ConflictError never leaves actives deprecated with no replacement created - Add onSend hook to echo request.id back as x-request-id response header, fulfilling the documented contract that clients can correlate error.request_id with the header - Accept rateLimitMax override in buildApp() to allow tests to trigger 429 without exhausting the real 100/min or 20/min limits - Add test: applies_to with empty array field returns 400 validation_error - Add test: appliesTo metadata normalized to lowercase, file_patterns casing preserved - Add test: 429 response has structured error.code, details.limit, request_id, and x-request-id header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix error message in appliesToSchema refine to use the API field name "applies_to" instead of the internal camelCase "appliesTo" - Fix @fastify/rate-limit v10 compatibility: errorResponseBuilder return value is thrown directly by the plugin, so the previous plain-object return fell through to the catch-all 500 handler. Now returns a proper Error with statusCode=429 attached, and setErrorHandler handles the 429 case explicitly, preserving the structured rate-limit error shape. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
findLatestByRuleKey and findActiveByRuleKey already filter by ruleKey in
the where clause, so including ruleKey in orderBy has no effect and
misleadingly implies multi-key result ordering. Simplify both to just
orderBy: { version: 'desc' }.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR applies 10 targeted hardening improvements across the repository, HTTP, validation/service, and infrastructure layers. Changes were developed in 4 parallel worktrees and merged with no conflicts.
Changes by Group
Repository Layer (
src/repositories/prisma-standards-repository.ts,src/domain/errors.ts)Explicit Prisma error handling
Previously, any Prisma constraint violation or missing-record error would bubble up as an unhandled exception and surface as a generic 500. All repository methods now catch
Prisma.PrismaClientKnownRequestErrorand translate:P2002(unique constraint violation) →ConflictError("A standard with this rule_key and version already exists")→ 409P2025(record not found) →NotFoundError→ 404This means concurrent writes to the same
(rule_key, version)pair return a clean 409 instead of leaking a Prisma stack trace.Stable pagination sort
findLatestByRuleKeyandfindActiveByRuleKeylacked an explicitorderBy, relying on undefined Postgres row ordering. Both now useorderBy: [{ ruleKey: 'asc' }, { version: 'desc' }], consistent with thelist()method and safe under concurrent inserts.HTTP / Middleware Layer (
src/app.ts,src/http/routes.ts,package.json)Request ID tracing
Fastify's
genReqIdis now configured to readx-request-idfrom the incoming request header (for upstream proxy continuity) or generate arandomUUID()if absent. Therequest.idis included in all error response bodies aserror.request_id, so clients can correlate errors with server logs without grepping timestamps.Rate limiting
@fastify/rate-limitis registered globally at 100 req/min per IP. Write endpoints (POST /api/v1/standardsandPUT /api/v1/standards/:ruleKey) have a tighter per-route override of 20 req/min. Rate limit errors return a structured JSON body:{ "error": { "code": "rate_limit_exceeded", "message": "Too many requests", "details": { "limit": 20, "after": "1 minute" } } }Auth failure logging
requireWriteApiKeypreviously threw silently. It now accepts the fullFastifyRequestand logs a structured warning before throwing:This enables alerting on brute-force attempts without exposing the key value in logs.
Validation / Service Layer (
src/validation/standards.ts,src/services/standards-service.ts)Lowercase normalization for
appliesTofieldscompactAppliesToin the service now lowercases values forlanguages,frameworks,runtimes,teams,repos, andenvironmentsbefore storage.file_patternsis intentionally excluded since globs are case-sensitive. This prevents the database accumulating"TypeScript"and"typescript"as distinct entries, and ensures applicability matching works regardless of caller casing.Reject empty
appliesToarraysThe
appliesToSchemanow has a.refine()that rejects any field present with a zero-length array.appliesTo: { languages: [] }previously silently matched no rules — now it fails validation with:Omitting a field entirely (i.e., not sending the key) continues to mean "match all."
Infrastructure (
Dockerfile,.dockerignore,src/server.ts)Pinned Node base image
All three Dockerfile stages (
deps,build,runtime) were updated from the floatingnode:22-slimto the pinnednode:22.3-alpine. This eliminates surprise Node minor-version upgrades in CI and reduces the image footprint (alpine vs. slim)..dockerignoreA
.dockerignorewas added to excludenode_modules,dist,.git,*.md,k8s/,test/,docker-compose.yml,.env*, and.claude/from the build context. Without it, the full repo (including test files, markdown, and local k8s configs) was copied into every build stage unnecessarily.Graceful Prisma disconnect (
src/server.ts)Confirmed already present in the shutdown handler — no change required.
Test Coverage
POSTrequests for the same(rule_key, version)usingPromise.allSettled. Asserts exactly one201and one409, exercising the newP2002error path end-to-end through the in-memory repository.Files Changed
src/repositories/prisma-standards-repository.tssrc/domain/errors.tsConflictErrorandNotFoundErrorif not presentsrc/app.tsgenReqId, rate-limit plugin,request_idin error responsessrc/http/routes.tssrc/services/standards-service.tssrc/validation/standards.ts.refine()rejecting empty array fields in appliesTotest/standards.test.tsDockerfilenode:22-slim→node:22.3-alpineacross all 3 stages.dockerignorepackage.json/package-lock.json@fastify/rate-limitTest Plan
npx tsc --noEmitpasses (confirmed clean before merge)npm test— all existing tests pass; concurrent creation test passesPOSTto a duplicate(rule_key, version)returns 409 witherror.code: "conflict"POST/PUTbeyond 20 req/min returns 429 witherror.code: "rate_limit_exceeded"error.request_idmatching thex-request-idresponse headerappliesTo: { languages: [] }in a create body returns 400appliesTo: { languages: ["TypeScript"] }stored as"typescript"in DBdocker build .🤖 Generated with Claude Code