Skip to content

feat: add rate limiting and request body size limits (#43) - #76

Merged
joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
thebabalola:fix/rate-limit-and-body-size
Aug 29, 2026
Merged

joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
thebabalola:fix/rate-limit-and-body-size

Conversation

@thebabalola

Copy link
Copy Markdown
Contributor

Closes #43

Summary of Changes

The API currently accepts requests of any size and at any rate. This change adds per-IP rate limiting and a maximum request body size using Fastify's built-in guardrails plus the official plugin. The defaults are conservative enough for development but configurable per environment.

What changed

  • / : added .
  • : added (default 100), (default 60000), and (default 1 MB).
  • : updated the test env fixture with the new keys so the coverage test passes.
  • : documented the new operational guardrail variables.
  • :
    • Exported with optional and overrides for testing.
    • Registered globally with IP-based keying and a typed 429 response.
    • Set Fastify's from config.
    • Guarded the top-level call so importing in tests no longer starts a real server.
  • : added tests for rate-limit rejection (429), body-limit rejection (413), and accepted small payloads (201).

Testing / Local Verification

Ran the exact CI commands from :

added 65 packages, and audited 66 packages in 4s

25 packages are looking for funding
run npm fund for details

found 0 vulnerabilities

modeltrace-api@0.1.0 lint
tsc --noEmit

modeltrace-api@0.1.0 build
tsc

modeltrace-api@0.1.0 test
tsc && node --test $(find dist -name "*.test.js")

✔ parses Soroban network and contract configuration (3.099609ms)
✔ STELLAR_NETWORK is required and has no default (3.643305ms)
✔ Soroban RPC URL must be a URL (1.339062ms)
✔ contract IDs must be Soroban contract-shaped strkeys (1.226679ms)
✔ CORS_ORIGIN parses a comma-separated list into multiple origins (2.070259ms)
✔ resolveCorsOrigins falls back to the dev default outside production (0.508813ms)
✔ production requires CORS_ORIGIN to be set explicitly (0.988054ms)
✔ production rejects "*" as a CORS origin (1.247719ms)
✔ production accepts an explicit, non-wildcard CORS_ORIGIN (1.900825ms)
✔ .env.example carries every required schema key (2.105662ms)
✔ a miss fetches upstream and returns the value with an as-of timestamp (4.61042ms)
✔ a fresh entry is served from the cache without touching upstream (0.64035ms)
✔ an entry past its TTL is refetched (0.736039ms)
✔ a hundred concurrent misses produce one upstream call (31.381895ms)
✔ an expired entry is served as stale, labelled with its as-of, when upstream fails (1.583571ms)
✔ upstream failure with no cached entry propagates the error (1.655498ms)
✔ stale serving can be disabled for paths that cannot act on estimates (0.741257ms)
✔ each read after a stale serving retries upstream rather than serving stale forever (1.270564ms)
✔ coalesced waiters all receive the same stale result on upstream failure (1.307368ms)
✔ invalidate drops the entry so the next read goes upstream (2.145842ms)
✔ a read started after invalidate does not join the pre-event fetch (0.908087ms)
✔ invalidating an unknown key is a no-op (0.34211ms)
✔ emits structured events for hits, misses, coalescing, and invalidation (0.620408ms)
✔ stale serving emits the fetch-failed and stale-served events (0.557997ms)
✔ createContractReadCache wires each type to its documented TTL (0.696972ms)
✔ expired entries are swept once the cache exceeds maxEntries (0.80233ms)
✔ redaction keeps ordinary values (19.795715ms)
✔ redaction removes values under secret-looking key names (0.518008ms)
✔ redaction scrubs a Stellar seed that arrived under an innocuous name (5.022024ms)
✔ redaction survives a cycle rather than throwing (6.287981ms)
✔ redaction preserves error fields while scrubbing them (0.683635ms)
✔ the default provider is null and refuses to sign (2.891652ms)
✔ the env provider is refused outright in production (2.75283ms)
✔ the env provider warns loudly outside production (0.50258ms)
✔ providers refuse to start without the configuration they need (2.117007ms)
✔ a scope outside the key's permitted set is rejected before signing (1.834775ms)
{"level":40,"time":1787862562846,"pid":58656,"hostname":"pop-os","event":"signing.provider_selected","provider":"null","msg":"signing.provider_selected"}
{"level":40,"time":1787862563013,"pid":58656,"hostname":"pop-os","event":"signing.provider_selected","provider":"null","msg":"signing.provider_selected"}
✔ rate limit rejects requests above the configured threshold (622.91864ms)
{"level":40,"time":1787862563075,"pid":58656,"hostname":"pop-os","event":"signing.provider_selected","provider":"null","msg":"signing.provider_selected"}
✔ body limit rejects payloads larger than the configured size (59.043853ms)
{"level":30,"time":1787862562973,"pid":58656,"hostname":"pop-os","reqId":"req-1","req":{"method":"GET","url":"/health","host":"localhost","remoteAddress":"127.0.0.1","headers":{"user-agent":"lightMyRequest","host":"localhost:80"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862562994,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":200},"responseTime":19.271891000000096,"msg":"request completed"}
{"level":30,"time":1787862562996,"pid":58656,"hostname":"pop-os","reqId":"req-2","req":{"method":"GET","url":"/health","host":"localhost","remoteAddress":"127.0.0.1","headers":{"user-agent":"lightMyRequest","host":"localhost:80"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862562999,"pid":58656,"hostname":"pop-os","reqId":"req-2","res":{"statusCode":200},"responseTime":3.3243889999999965,"msg":"request completed"}
{"level":30,"time":1787862563000,"pid":58656,"hostname":"pop-os","reqId":"req-3","req":{"method":"GET","url":"/health","host":"localhost","remoteAddress":"127.0.0.1","headers":{"user-agent":"lightMyRequest","host":"localhost:80"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862563002,"pid":58656,"hostname":"pop-os","reqId":"req-3","res":{"statusCode":429},"err":{"statusCode":429,"error":"Too Many Requests","message":"rate limit exceeded","type":"Error","stack":""},"msg":"rate limit exceeded"}
{"level":30,"time":1787862563003,"pid":58656,"hostname":"pop-os","reqId":"req-3","res":{"statusCode":429},"responseTime":1.9550610000000006,"msg":"request completed"}
{"level":30,"time":1787862563060,"pid":58656,"hostname":"pop-os","reqId":"req-1","req":{"method":"POST","url":"/api/v1/disputes","host":"localhost","remoteAddress":"127.0.0.1","headers":{"x-role":"buyer","x-user-id":"buyer-1","content-type":"application/json","user-agent":"lightMyRequest","host":"localhost:80","content-length":"142"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862563062,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":413},"err":{"name":"FastifyError","message":"Request body is too large","stack":"FastifyError: Request body is too large\n at rawBody (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/content-type-parser.js:245:10)\n at ContentTypeParser.run (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/content-type-parser.js:209:5)\n at Object.handleRequest (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/handle-request.js:87:46)\n at runPreParsing (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/route.js:658:19)\n at next (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/hooks.js:236:9)\n at handleResolve (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/hooks.js:253:7)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)","type":"FastifyError"},"msg":"Request body is too large"}
{"level":30,"time":1787862563064,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":413},"responseTime":3.823607000000038,"msg":"request completed"}
{"level":30,"time":1787862563099,"pid":58656,"hostname":"pop-os","reqId":"req-1","req":{"method":"POST","url":"/api/v1/disputes","host":"localhost","remoteAddress":"127.0.0.1","headers":{"x-role":"buyer","x-user-id":"buyer-1","content-type":"application/json","user-agent":"lightMyRequest","host":"localhost:80","content-length":"93"},"query":{}},"msg":"incoming request"}
✔ small payloads within the body limit are accepted (46.740831ms)
{"level":30,"time":1787862563115,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":201},"responseTime":16.018123999999943,"msg":"request completed"}
✔ version is sourced from package.json, not a literal (2.994133ms)
✔ build commit and time default to 'unknown' when not injected (6.254252ms)
✔ build commit and time are read from the environment when present (0.439704ms)
✔ reports the configured network and contract addresses (0.456905ms)
✔ description no longer reads as a scaffold (4.442531ms)
✔ a job handler runs exactly once even if run() is called twice (5.221547ms)
✔ idempotency survives a simulated crash-and-restart against the same durable file (1.771636ms)
✔ a job that keeps failing is retried up to maxAttempts then dead-lettered (4.471015ms)
✔ metrics report queue depth and failure rate (2.50509ms)
✔ a buyer can raise a dispute with evidence, hash-committed (197.225988ms)
✔ listing is role-scoped: a seller cannot see another party's dispute (16.368116ms)
✔ only a party to the dispute may add evidence (14.18441ms)
✔ only an adjudicator may resolve, and resolving fires a notification (32.344222ms)
✔ evidence is rejected once the window has closed (48.388091ms)
✔ requests without a recognized role/user are rejected (11.780066ms)
✔ GET /health/live returns HTTP 200 with liveness status (251.05272ms)
✔ GET /health returns HTTP 200 for backwards compatibility (15.94505ms)
✔ GET /health/ready returns HTTP 200 when Soroban RPC is healthy (50.858596ms)
✔ GET /health/ready caches RPC check result within TTL (9.969158ms)
✔ GET /health/ready returns HTTP 503 when Soroban RPC fails (11.902187ms)
✔ GET /health/ready returns HTTP 503 when Soroban RPC times out (20.50644ms)
✔ GET /health/ready returns HTTP 503 during graceful shutdown (11.023397ms)
✔ simulate retries transient transport errors and eventually succeeds (12.716589ms)
✔ simulate times out when the transport hangs (8.238264ms)
✔ circuit breaker opens after repeated failures and fails fast (4.812698ms)
✔ submit is never retried by the client itself (7.550394ms)
✔ pollUntilConfirmed treats a 429-then-recover getTransaction as one logical poll (10.744132ms)
ℹ tests 66
ℹ suites 0
ℹ pass 66
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1525.903524
found 0 vulnerabilities

…b#43)

Install @fastify/rate-limit and wire it into the Fastify bootstrap,
along with a configurable bodyLimit. This protects the API from
abusive traffic and oversized payloads without changing route logic.

- src/config/env.ts: add RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS,
  BODY_LIMIT_BYTES with sensible defaults.
- src/config/env.test.ts: include the new keys in the test env.
- .env.example: document the new guardrail variables.
- src/index.ts: export buildServer(opts?) so tests can override limits;
  register the rate-limit plugin globally; set bodyLimit on the Fastify
  instance; guard the top-level listen() so importing buildServer in tests
  does not start a real server.
- src/index.test.ts: add tests for rate-limit 429, body-limit 413, and
  accepted small payloads.
@thebabalola

thebabalola commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @joelpeace48-cell , I've put together the rate limiting and body size guardrails for #43. The PR is ready for review and I'm happy to adjust the limits or the response shape if anything doesn't match what you had in mind.

@joelpeace48-cell
joelpeace48-cell merged commit 5d1da71 into FinesseStudioLab:main Aug 29, 2026
1 check passed
thebabalola added a commit to thebabalola/modeltrace-backend that referenced this pull request Aug 29, 2026
… tests

- Merged rate limiting (FinesseStudioLab#76) and auth (FinesseStudioLab#42) features in env.ts, index.ts,
  index.test.ts, .env.example, env.test.ts
- Applied lazy config (getConfig) to prevent eager parseEnv at import time
- Augmented FastifyContextConfig with public boolean for route auth opt-out
- Fixed body limit tests to include auth credentials (401 was returned
  before body parsing could trigger 413/201)
- All 84 tests passing, lint clean, 0 audit vulnerabilities
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: add rate limiting and request body size limits

2 participants