Skip to content

feat: add @nest-native/lockout login lockout (eighth library)#59

Merged
rodrigobnogueira merged 1 commit into
mainfrom
feat/login-lockout
Jul 19, 2026
Merged

feat: add @nest-native/lockout login lockout (eighth library)#59
rodrigobnogueira merged 1 commit into
mainfrom
feat/login-lockout

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Contributor

What

Dogfoods @nest-native/lockout in the auth flow, bringing the reference app to 8/8 nest-native libraries. Failed logins now lock an identity before the credential is checked; the lock is keyed by email OR source IP, backed by a Drizzle counter on the same SQLite database, returns a Retry-After, and resets on a successful login.

Why LockoutService, not LockoutGuard

LockoutGuard's default extractor reads the credential from the HTTP request body (req.body). tRPC's batched/superjson payload isn't shaped that way, so the guard doesn't fit. This app uses the library's documented non-HTTP-transport recipe: call LockoutService from the login handler, building the identity from the validated input plus a source IP the tRPC context exposes. That friction is exactly what surfaced the doc/API gaps below — the feedback loop working as intended.

The library improvement this surfaced (released first, per the repo's feedback-loop rule)

Wiring the Drizzle-backed store needed a typed async factory:

LockoutModule.forRootAsync({
  inject: [getDrizzleClientToken()],
  useFactory: (db: AppDatabase) => ({ store: new SqliteLockoutStore(db, lockoutAttempts), ... }),
})

LockoutModuleAsyncOptions.useFactory was typed (...args: unknown[]), and under strictFunctionTypes a factory with a typed injected parameter is not assignable to it — forcing callers to widen to unknown and re-narrow. Fixed upstream to (...args: any[]) (matching NestJS's own FactoryProvider.useFactory) and shipped as @nest-native/lockout@0.3.1 (adapter-only; @authlock/core unchanged at 0.3.0) before this integration, which now pins ^0.3.1. The 0.3.1 docs also gained the non-HTTP-transport note above.

Changes

  • schema + migration 0005lockout_attempts via sqliteLockoutTable from @authlock/core/sqlite (byte-identical DDL, same precedent as the jobs/messaging tables)
  • auth.service.ts — pre-auth check → 429 HttpException (mapped to tRPC TOO_MANY_REQUESTS); reportFailure on bad credentials, reportSuccess on login. Injected by explicit class token (@Inject(LockoutService)) like every other provider here — esbuild/tsx doesn't emit design:paramtypes, so the app never relies on type-based DI
  • lockout.setup.tsAppLockoutModule wires the store on the global Drizzle client, keyed [['email'], ['ip']], outside the request transaction so a failure is recorded even if the login tx rolls back
  • configLOCKOUT_LIMIT / LOCKOUT_COOLOFF_MS (defaults 5 / 15m)
  • tRPC context — exposes the source IP (req.ip ?? req.socket?.remoteAddress; connection address, not a proxy header)
  • README + constitution — reframed seven → eight libraries/chapters (new chapter row, 8/8 badge)

Tests / gates

New e2e spec test/e2e/auth-lockout.spec.ts: after LIMIT failed attempts the identity is locked and even the correct password is refused with 429 — the brute-force control gates before the credential check.

All green locally: typecheck, lint, complexity (≤15), client-smoke:typecheck, and test (84 pass, 0 fail; the live-Kafka spec self-skips — Docker not run). Mutation testing not run (docs + wiring change, no reworked algorithm).

Dogfood @nest-native/lockout in the auth flow. Failed logins now lock an
identity (by email OR source IP) *before* the credential is checked, with a
Drizzle-backed counter on the app's SQLite DB, a Retry-After on the lock, and
reset on success. tRPC carries no HTTP request body for the guard's default
extractor to read, so the login handler calls LockoutService directly — the
library's documented non-HTTP-transport recipe.

- schema + migration 0005: lockout_attempts (sqliteLockoutTable from @authlock/core)
- config: LOCKOUT_LIMIT / LOCKOUT_COOLOFF_MS (defaults 5 / 15m)
- tRPC context exposes the source IP so the login procedure can key on it
- e2e spec: N failures -> 429, and the correct password is refused while locked
- README + constitution reframed seven -> eight libraries/chapters

Requires @nest-native/lockout 0.3.1: integrating here surfaced that
LockoutModuleAsyncOptions.useFactory was typed (...args: unknown[]), which a
typed factory parameter (db: AppDatabase) => ... does not satisfy under
strictFunctionTypes. Fixed upstream to (...args: any[]) — matching NestJS's own
FactoryProvider.useFactory — and released as 0.3.1 before wiring it in here.
@rodrigobnogueira
rodrigobnogueira merged commit a56f01c into main Jul 19, 2026
1 check passed
@rodrigobnogueira
rodrigobnogueira deleted the feat/login-lockout branch July 19, 2026 03:13
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.

1 participant