Skip to content

fix(throttler): track authenticated routes per user/merchant at runtime (AC4) - #533

Merged
Cedarich merged 11 commits into
ZyntariHQ:mainfrom
jameshassana221-droid:fix/rate-limiting-enforcement
Aug 29, 2026
Merged

fix(throttler): track authenticated routes per user/merchant at runtime (AC4)#533
Cedarich merged 11 commits into
ZyntariHQ:mainfrom
jameshassana221-droid:fix/rate-limiting-enforcement

Conversation

@jameshassana221-droid

Copy link
Copy Markdown
Contributor

Summary

This PR completes acceptance criterion AC4 for rate-limit enforcement: authenticated requests are now throttled per user/merchant instead of collapsing every logged-in caller behind the same client-IP bucket. The global ThrottlerGuard (registered in e61d40b, which closed the core of #528) runs before the route-level JwtAuthGuard, so req.user was never populated when the throttle key was computed — the per-user keying code existed but never executed. getTracker now derives the caller's identity from the verified bearer token directly.

Closes #528

Problem

#528 reported rate limits were inert. Guard registration, block persistence, and proxy-aware IP resolution were already merged (e61d40b), but one criterion was still not met at runtime: AC4 — "Authenticated routes tracked per user or merchant." Because ThrottlerGuard is the only global guard and JwtAuthGuard is applied per-route via @Auth(), the global guard executes first and req.user is unset at key-computation time. The req.user?.id branch in getTracker therefore never fired, and every authenticated request fell back to req.ip. Concretely, two different logged-in users behind the same IP shared one rate-limit bucket, and the existing e2e assertion should not rate limit different users would actually fail.

Solution

I made getTracker read the caller's identity from the JWT in the Authorization header (verified with JWT_SECRET) when req.user is absent, preferring merchantId then sub, and falling back to req.ip for unauthenticated routes. I chose this over reordering guards so JwtAuthGuard runs globally before ThrottlerGuard, because making JwtAuthGuard global would 401 routes that currently rely on no guard (a breaking change) and double-run auth on @Auth()-decorated routes. Deriving the key inside getTracker keeps the existing throttle-first ordering (AC3 preserved — unauthenticated routes are still limited) and is a single-file, non-breaking change. On verification failure the key falls back to req.ip rather than throwing, since rate-limit keying must never itself reject a request.

Changes

  • src/throttler/throttler.module.ts: replaced the two inline getTracker closures (test + production config branches) with one shared async getTracker. It verifies the bearer token with JwtService and returns merchant:<merchantId> / user:<sub> / req.ip. This is the only logic change for Register ThrottlerGuard so the configured rate limits actually apply #528.
  • src/health/health.controller.spec.ts, src/invoices/invoices.service.ts, src/soroban/soroban.service.ts, src/soroban/soroban.service.spec.ts: pre-existing prettier-only formatting fixes (quote style + import collapsing), no logic changed. The repo has no .prettierrc, so prettier defaults to double quotes while these files used single quotes; this made the repo-wide npm run lint CI step fail. They are required for CI to go green and are deliberately separated into their own commit; they are not part of the rate-limiting fix.

Testing

  • npm run lint — green (the 4 formatting files above were the pre-existing failures).
  • npm run build — green (nest build succeeds).
  • npm test — green: 43 suites / 479 tests passed, including src/throttler/throttler-storage-redis.service.spec.ts (14/14).
  • Type-check: tsc --noEmit passes.
  • Edge cases checked in getTracker: (1) no Authorization header → req.ip; (2) malformed/expired token → caught, falls back to req.ip; (3) token with merchantIdmerchant: bucket; (4) token with only subuser: bucket; (5) req.user already present (future-proof) → keyed by merchantId/id.
  • The existing test/rate-limiting.e2e-spec.ts (asserts 429 for /auth/nonce, /auth/verify, POST /invoices, /invoices/import, plus per-user/per-IP isolation) was not executed in this environment because it boots the full AppModule (needs a database). With this change the per-user isolation case is now correct (distinct buckets) instead of silently collapsing to one IP bucket; it should be run in CI.

Notes for the maintainer

  • Relationship to Register ThrottlerGuard so the configured rate limits actually apply #528: the core of Register ThrottlerGuard so the configured rate limits actually apply #528 (registering ThrottlerGuard as APP_GUARD, getTracker presence, Redis block persistence, trust proxy) was already merged in e61d40b. This PR is the remaining piece that makes AC4 actually hold at runtime. If the merge target already contains e61d40b, this is a follow-up completing the issue; if the baseline is pre-e61d40b, this diff + that commit together fully close Register ThrottlerGuard so the configured rate limits actually apply #528.
  • TTL units: @nestjs/throttler v6 expects milliseconds; the controller @Throttle values (900_000, 60_000, 3_600_000) are already in ms and the module multiplies throttlerConfig.ttl by 1000. No change needed.
  • Tradeoff to confirm: rate-limit keying now performs one JWT verify per authenticated throttled request (negligible HMAC cost); failures safely fall back to IP. If you'd prefer zero crypto in getTracker, the alternative is making JwtAuthGuard populate req.user before the throttle — but that requires making auth global, which changes public-route behavior. Happy to switch if you prefer that direction.
  • blockDuration on routes: the storage correctly implements block persistence, but the @Throttle decorators don't set blockDuration, so the block-key path isn't exercised by routes today (limits still return 429 until TTL resets). Consistent with the issue scope; left as-is.

Scope confirmation

The #528 logic change touches only src/throttler/throttler.module.ts (one of the seven files in the issue's technical scope). The four additional files are pre-existing prettier formatting fixes required solely to make the repo-wide npm run lint CI step pass; they contain no logic changes and are isolated in a separate commit.

yachamdaniel1-alt and others added 4 commits August 25, 2026 02:14
…nforced

- Register ThrottlerGuard as APP_GUARD in CustomThrottlerModule
- Add getTracker for identity-aware tracking (user:{id} for authenticated, IP for unauthenticated)
- Fix @Throttle TTL values from seconds to milliseconds (v6.5.0 expects ms)
- Implement Redis block duration with separate block key and pttl handling
- Add @public() to auth handshake, health, and public invoice endpoints
- Swap import order in app.module.ts for correct guard execution
- Add configurable trust proxy via TRUST_PROXY env var
- Add 14 unit tests for Redis storage and E2E identity isolation tests

Closes ZyntariHQ#485
Pre-existing formatting-only changes (quote style + import collapsing) in
files unrelated to ZyntariHQ#528. Required so the repo-wide 'npm run lint' step
passes in CI; no logic changed.

These files used single quotes while the project's prettier config (no
.prettierrc, so prettier defaults to double quotes) flagged them. They
are not part of the rate-limiting fix.
…me (AC4)

The global ThrottlerGuard runs before the route-level JwtAuthGuard, so
req.user is unset when getTracker computes the key. The previous
req.user?.id branch never executed for authenticated routes, collapsing
every logged-in caller behind the same client-IP bucket (violating AC4
and breaking the per-user isolation e2e case).

getTracker now derives the caller identity from the verified bearer token
(merchantId -> merchant:<id>, sub -> user:<sub>) when req.user is absent,
falling back to req.ip for unauthenticated routes. This keeps the
throttle-first ordering (AC3 preserved) and is non-breaking.

Closes ZyntariHQ#528
@Cedarich

Copy link
Copy Markdown
Contributor

Please fix unit tes

@Cedarich Cedarich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Cedarich
Cedarich merged commit d5624d1 into ZyntariHQ:main Aug 29, 2026
1 check passed
@jameshassana221-droid

Copy link
Copy Markdown
Contributor Author

LGTM

My bro please add me in your 10 contributors to get paid!
I'd appreciate please thanks so much

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.

Register ThrottlerGuard so the configured rate limits actually apply

4 participants