diff --git a/GUIDELINES_NEST_REFERENCE_APP.md b/GUIDELINES_NEST_REFERENCE_APP.md
index 71c8797..e35ac44 100644
--- a/GUIDELINES_NEST_REFERENCE_APP.md
+++ b/GUIDELINES_NEST_REFERENCE_APP.md
@@ -1,13 +1,13 @@
# GUIDELINES_NEST_REFERENCE_APP.md
-## Core Philosophy - This app SERVES the seven nest-native libraries
+## Core Philosophy - This app SERVES the eight nest-native libraries
This is `nest-native/reference-app`: a production-shaped, multi-tenant
-work-tracking SaaS whose only job is to put **all seven
+work-tracking SaaS whose only job is to put **all eight
[nest-native](https://github.com/nest-native) libraries** — `drizzle`, `trpc`,
-`messaging`, `kafka`, `jobs`, `asyncapi`, and `ai-sdk` — under realistic
-backend pressure and show how they compose. It is **read first and run
-second**. Every decision optimizes for a reader who wants to lift a pattern
+`messaging`, `kafka`, `jobs`, `asyncapi`, `ai-sdk`, and `lockout` — under
+realistic backend pressure and show how they compose. It is **read first and
+run second**. Every decision optimizes for a reader who wants to lift a pattern
into their own app.
Follow these assumptions in **EVERY** file you generate or modify. This is the
@@ -15,10 +15,10 @@ app's constitution.
### 1. What This App Is (and Is Not)
-- It **serves the libraries**. Every module exists to exercise one of the seven
+- It **serves the libraries**. Every module exists to exercise one of the eight
under realistic pressure — persistence, a typed API, reliable domain events,
an event backbone, deferred jobs, a documented event catalog, a streaming AI
- assistant.
+ assistant, and brute-force login lockout.
- It is **not** a product, **not** a starter or scaffold, and **not** a generic
NestJS boilerplate. There is no CLI, no second frontend, no multi-database
story, no GraphQL — those are deliberate omissions, not gaps to fill.
diff --git a/README.md b/README.md
index 31552cb..ed17027 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,10 @@
-
+
-A production-shaped **multi-tenant work-tracking SaaS** — think a small Linear/Asana — that puts **all seven [nest-native](https://github.com/nest-native) libraries** under realistic backend pressure and composes them the way a real product would: persistence, a typed API, reliable domain events, an event backbone, deferred background jobs, a documented event catalog, and a streaming AI assistant.
+A production-shaped **multi-tenant work-tracking SaaS** — think a small Linear/Asana — that puts **all eight [nest-native](https://github.com/nest-native) libraries** under realistic backend pressure and composes them the way a real product would: persistence, a typed API, reliable domain events, an event backbone, deferred background jobs, a documented event catalog, a streaming AI assistant, and brute-force login lockout.
It is not a product. It **serves the libraries** — a credible demo a team could adapt, and a feedback loop: if something feels awkward here, that's signal to add API in a separate PR to the relevant library (that's how, for example, `MessagingModule.forRoot`'s `imports` option and `KafkaInboxConsumer`'s `dedupKey` argument came to exist).
@@ -21,7 +21,7 @@ Follow one journey through the code and every library shows up where a real syst
5. **The event contracts are published.** An **AsyncAPI 3.0 catalog** at `/asyncapi` documents every event so another team could subscribe to your streams.
6. **AI reads the activity.** A streaming **project assistant** turns a project's recent activity into a status update, token by token.
-## Seven libraries, seven chapters
+## Eight libraries, eight chapters
| Library | Its job in the story | Where in the code |
| --- | --- | --- |
@@ -32,6 +32,7 @@ Follow one journey through the code and every library shows up where a real syst
| [`@nest-native/jobs`](https://github.com/nest-native/jobs) | **Deferred work** — the assignment reminder: enqueued in the same transaction as the `task.assigned` projection (`uniqueKey` = the event's dedup key), executed exactly once by the worker | `src/modules/reminders/`, `TaskAssignedProjection` in `src/modules/activity/`, `src/database/schema/jobs.ts` |
| [`@nest-native/asyncapi`](https://github.com/nest-native/asyncapi) | **The event catalog** — an AsyncAPI 3.0 doc so other teams integrate with your streams | `src/modules/events-catalog/`, served at `/asyncapi` from `src/main.ts` |
| [`@nest-native/ai-sdk`](https://github.com/nest-native/ai-sdk) | **AI over your data** — a streaming assistant that summarizes a project's activity | `src/modules/assistant/` (`@AiStream`), `POST /projects/:projectId/assistant` |
+| [`@nest-native/lockout`](https://github.com/nest-native/lockout) | **Brute-force defense** — failed logins lock an identity (by email OR source IP) *before* the credential is checked; a Drizzle-backed counter on the same SQLite DB, `Retry-After` on the lock, reset on success. tRPC has no HTTP body for the guard's extractor to read, so the login handler calls `LockoutService` directly — the library's documented non-HTTP-transport recipe | `src/auth/lockout.setup.ts` (`LockoutModule.forRootAsync`), `src/auth/auth.service.ts` (`check` → `reportFailure`/`reportSuccess`) |
Underneath, [`@nestjs-cls/transactional`](https://www.npmjs.com/package/@nestjs-cls/transactional) (with the official Drizzle adapter) ties the outbox write to the business write — its `transactionMode: 'auto'` runs the same `@Transactional()` code against better-sqlite3's synchronous driver locally and an async driver in production.
@@ -90,7 +91,7 @@ src/
app.module.ts Root module, ClsPluginTransactional, in-process/Kafka messaging profiles
config/env.ts loadEnv() — single source of truth (incl. the optional kafka block)
database/ DrizzleModule wiring + schema (orgs/users/projects/tasks/activity/...) + migrations
- auth/ scrypt passwords, HS256 JWT, AuthGuard, middleware
+ auth/ scrypt passwords, HS256 JWT, AuthGuard, middleware; @nest-native/lockout login lockout (lockout.setup.ts)
context/ request-scoped CURRENT_USER / CURRENT_ORGANIZATION
modules/
organizations/ users/ projects/ repos + services + tRPC routers (tenant-scoped)
@@ -116,7 +117,7 @@ npm run test:cov # with c8 coverage
npm run ci # typecheck, lint, complexity (≤15), test:cov, security:audit, build
```
-Coverage here is **pragmatic, not 100%** — the 100% bar belongs to the libraries. The transactional workflow, the outbox worker, the inbox dedup, the reminder job's exactly-once scheduling and execution, the AsyncAPI catalog, and the AI stream all have explicit tests. CI runs on **Node 22**.
+Coverage here is **pragmatic, not 100%** — the 100% bar belongs to the libraries. The transactional workflow, the outbox worker, the inbox dedup, the reminder job's exactly-once scheduling and execution, the AsyncAPI catalog, the AI stream, and the login-lockout gate (fail N times → 429, even the right password is refused while locked) all have explicit tests. CI runs on **Node 22**.
Two **optional, local-only** layers sit on top (neither runs in CI, and forks
work without them):
@@ -141,6 +142,7 @@ ritual, and agent instructions — in
| NestJS | `11.x` |
| `@nest-native/drizzle` | `0.3.x` · `@nest-native/trpc` `0.6.x` · `@nest-native/kafka` `0.2.x` |
| `@nest-native/messaging` | `0.2.x` · `@nest-native/asyncapi` `0.1.x` · `@nest-native/ai-sdk` `0.4.x` (on `ai@7`) |
+| `@nest-native/lockout` | `0.3.x` (on `@authlock/core` `0.3.x`) |
| Drizzle ORM | `0.45.x` · `@nestjs-cls/transactional` `^3` |
## Philosophy
diff --git a/package-lock.json b/package-lock.json
index 274ed38..183ddf9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,13 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
- "@ai-sdk/openai": "4.0.11",
+ "@authlock/core": "^0.3.0",
"@nest-native/ai-sdk": "^0.5.0",
"@nest-native/asyncapi": "^0.2.0",
"@nest-native/drizzle": "^0.4.0",
"@nest-native/jobs": "^0.1.0",
"@nest-native/kafka": "^0.3.0",
+ "@nest-native/lockout": "^0.3.1",
"@nest-native/messaging": "^0.3.1",
"@nest-native/trpc": "^0.6.0",
"@nestjs-cls/transactional": "^3.2.1",
@@ -212,6 +213,23 @@
"@types/json-schema": "^7.0.11"
}
},
+ "node_modules/@authlock/core": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@authlock/core/-/core-0.3.0.tgz",
+ "integrity": "sha512-u31tgHriUG9YwoeKHyVh9b/cqSmNXwgr+zDTdF49+hOdXqyvbpwYGcOjrG8pRSJmXqhNkcNKEUgdwggW0uCgbQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "drizzle-orm": "^0.44.0 || ^0.45.0"
+ },
+ "peerDependenciesMeta": {
+ "drizzle-orm": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -2518,6 +2536,24 @@
}
}
},
+ "node_modules/@nest-native/lockout": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@nest-native/lockout/-/lockout-0.3.1.tgz",
+ "integrity": "sha512-dLBHPAAV15WYCMC6UfDOAp4zN4r9K8VZf+0IfgAd5kn3wxvu3wkNHJCpg1NYJtwIIPKR2vxujjDUxss5dRKXYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@authlock/core": "0.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
+ "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
+ "reflect-metadata": "^0.1.12 || ^0.2.0",
+ "rxjs": "^7.0.0"
+ }
+ },
"node_modules/@nest-native/messaging": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@nest-native/messaging/-/messaging-0.3.1.tgz",
diff --git a/package.json b/package.json
index 327951a..1dfd581 100644
--- a/package.json
+++ b/package.json
@@ -44,11 +44,13 @@
"test:mutant:full": "npm run test:mutant && npm run test:kafka:mutant"
},
"dependencies": {
+ "@authlock/core": "^0.3.0",
"@nest-native/ai-sdk": "^0.5.0",
"@nest-native/asyncapi": "^0.2.0",
"@nest-native/drizzle": "^0.4.0",
"@nest-native/jobs": "^0.1.0",
"@nest-native/kafka": "^0.3.0",
+ "@nest-native/lockout": "^0.3.1",
"@nest-native/messaging": "^0.3.1",
"@nest-native/trpc": "^0.6.0",
"@nestjs-cls/transactional": "^3.2.1",
diff --git a/src/auth/auth-context.ts b/src/auth/auth-context.ts
index 0aae305..1c63752 100644
--- a/src/auth/auth-context.ts
+++ b/src/auth/auth-context.ts
@@ -15,5 +15,8 @@ export interface AuthContext {
export interface AuthenticatedRequest {
headers: Record;
+ /** Connection address (populated by the HTTP platform). NOT a proxy header. */
+ ip?: string;
+ socket?: { remoteAddress?: string };
authContext?: AuthContext;
}
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index c389194..462e9b5 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -10,9 +10,10 @@ import { AuthGuard } from './auth.guard';
import { AuthMiddleware } from './auth.middleware';
import { AuthRouter } from './auth.router';
import { AuthService } from './auth.service';
+import { AppLockoutModule } from './lockout.setup';
@Module({
- imports: [DatabaseModule],
+ imports: [DatabaseModule, AppLockoutModule],
providers: [
{
provide: AUTH_CONFIG,
diff --git a/src/auth/auth.router.ts b/src/auth/auth.router.ts
index d5db06c..eddd32d 100644
--- a/src/auth/auth.router.ts
+++ b/src/auth/auth.router.ts
@@ -27,8 +27,11 @@ export class AuthRouter {
constructor(@Inject(AuthService) private readonly auth: AuthService) {}
@Mutation({ input: LoginInputSchema, output: LoginOutputSchema })
- login(@Input() input: z.infer) {
- return this.auth.login(input);
+ login(
+ @Input() input: z.infer,
+ @TrpcContext('ip') ip: string | undefined,
+ ) {
+ return this.auth.login(input, ip);
}
@Query({ output: MeOutputSchema })
diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts
index 3ba059c..07f1c3e 100644
--- a/src/auth/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -1,6 +1,13 @@
-import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
+import {
+ HttpException,
+ HttpStatus,
+ Inject,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { InjectDrizzle } from '@nest-native/drizzle';
+import { LockoutService } from '@nest-native/lockout';
import type { AppDatabase } from '../database/database';
import { memberships, users } from '../database/schema';
import { AUTH_CONFIG, type AuthConfig } from './auth.config';
@@ -25,17 +32,45 @@ export class AuthService {
constructor(
@InjectDrizzle() private readonly db: AppDatabase,
@Inject(AUTH_CONFIG) private readonly config: AuthConfig,
+ // Explicit token like every other provider here: esbuild/tsx doesn't emit
+ // `design:paramtypes`, so this app never relies on type-based DI.
+ @Inject(LockoutService) private readonly lockout: LockoutService,
) {}
- login(input: LoginInput): LoginResult {
+ async login(
+ input: LoginInput,
+ ip: string | undefined,
+ ): Promise {
+ // Lock by email OR source IP. `email` is a custom identity dimension; the IP
+ // comes from the tRPC context (see trpc.module.ts) — do NOT trust proxy
+ // headers unless the platform's `trust proxy` is configured for them.
+ const identity = { email: input.email, ip };
+
+ // Pre-auth gate: reject a locked identity before touching the credential.
+ const gate = await this.lockout.check(identity);
+ if (gate.locked) {
+ // @nest-native/trpc maps this HttpException to a TRPCError('TOO_MANY_REQUESTS').
+ throw new HttpException(
+ {
+ statusCode: HttpStatus.TOO_MANY_REQUESTS,
+ error: 'Too Many Requests',
+ message: 'Too many failed login attempts. Try again later.',
+ retryAfterMs: gate.retryAfterMs,
+ },
+ HttpStatus.TOO_MANY_REQUESTS,
+ );
+ }
+
const user = this.db
.select()
.from(users)
.where(eq(users.email, input.email))
.get();
if (!user || !verifyPassword(input.password, user.passwordHash)) {
+ await this.lockout.reportFailure(identity);
throw new UnauthorizedException('Invalid email or password');
}
+ await this.lockout.reportSuccess(identity);
const membership = this.db
.select()
diff --git a/src/auth/lockout.setup.ts b/src/auth/lockout.setup.ts
new file mode 100644
index 0000000..8f8cda6
--- /dev/null
+++ b/src/auth/lockout.setup.ts
@@ -0,0 +1,40 @@
+import { Logger, Module } from '@nestjs/common';
+import { SqliteLockoutStore } from '@authlock/core/sqlite';
+import { getDrizzleClientToken } from '@nest-native/drizzle';
+import { LockoutModule } from '@nest-native/lockout';
+import { loadEnv } from '../config/env';
+import type { AppDatabase } from '../database/database';
+import { lockoutAttempts } from '../database/schema';
+
+/**
+ * Wires @authlock/core into the app: a Drizzle-backed lockout store on the SAME
+ * SQLite database as everything else (its atomic increment counts failed logins
+ * exactly once, even across instances), keyed by email OR source IP. The counter
+ * is written on the base Drizzle connection — deliberately NOT inside the request
+ * transaction — so a failed attempt is recorded even if the login transaction
+ * rolls back. `LockoutService` is global (LockoutModule default), so AuthService
+ * injects it directly.
+ */
+@Module({
+ imports: [
+ LockoutModule.forRootAsync({
+ // The drizzle client token is global (see DatabaseModule), so the store
+ // factory resolves the base Drizzle instance directly.
+ inject: [getDrizzleClientToken()],
+ useFactory: (db: AppDatabase) => {
+ const env = loadEnv();
+ const logger = new Logger('Lockout');
+ return {
+ store: new SqliteLockoutStore(db, lockoutAttempts),
+ limit: env.lockoutLimit,
+ cooloffMs: env.lockoutCooloffMs,
+ // A lock trips if EITHER the email OR the source IP crosses the limit.
+ parameters: [['email'], ['ip']],
+ logger: (error, context) =>
+ logger.error(`lockout store error (${context})`, error),
+ };
+ },
+ }),
+ ],
+})
+export class AppLockoutModule {}
diff --git a/src/config/env.ts b/src/config/env.ts
index 23d7f12..40ec48f 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -21,6 +21,10 @@ export interface AppEnv {
trpcPath: string;
authSecret: string;
authTtlSeconds: number;
+ /** Login lockout: failures allowed before an identity is locked. */
+ lockoutLimit: number;
+ /** Login lockout: how long a locked identity stays locked, in ms. */
+ lockoutCooloffMs: number;
outbox: {
pollIntervalMs: number;
batchSize: number;
@@ -125,6 +129,8 @@ export function loadEnv(): AppEnv {
trpcPath: process.env.TRPC_PATH ?? '/trpc',
authSecret: readAuthSecret(nodeEnv),
authTtlSeconds: Number.parseInt(process.env.AUTH_TTL_SECONDS ?? '3600', 10),
+ lockoutLimit: readIntFromEnv('LOCKOUT_LIMIT', 5),
+ lockoutCooloffMs: readIntFromEnv('LOCKOUT_COOLOFF_MS', 15 * 60_000),
outbox: {
pollIntervalMs: readIntFromEnv('OUTBOX_POLL_MS', 2_000),
batchSize: readIntFromEnv('OUTBOX_BATCH_SIZE', 32),
diff --git a/src/database/migrations/0005_groovy_war_machine.sql b/src/database/migrations/0005_groovy_war_machine.sql
new file mode 100644
index 0000000..b52944e
--- /dev/null
+++ b/src/database/migrations/0005_groovy_war_machine.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `lockout_attempts` (
+ `key` text PRIMARY KEY NOT NULL,
+ `failures` integer NOT NULL,
+ `first_failure_at` integer NOT NULL,
+ `last_failure_at` integer NOT NULL
+);
diff --git a/src/database/migrations/meta/0005_snapshot.json b/src/database/migrations/meta/0005_snapshot.json
new file mode 100644
index 0000000..a51888c
--- /dev/null
+++ b/src/database/migrations/meta/0005_snapshot.json
@@ -0,0 +1,932 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "b32423b0-dc5f-4cc6-b364-565cd3ea17cd",
+ "prevId": "ba4c98a7-99f7-4d8a-a44e-4c36f58c4da4",
+ "tables": {
+ "activity_events": {
+ "name": "activity_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dedup_key": {
+ "name": "dedup_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "activity_events_org_dedup_key_unique": {
+ "name": "activity_events_org_dedup_key_unique",
+ "columns": [
+ "org_id",
+ "dedup_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "activity_events_org_id_organizations_id_fk": {
+ "name": "activity_events_org_id_organizations_id_fk",
+ "tableFrom": "activity_events",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "activity_events_actor_user_id_users_id_fk": {
+ "name": "activity_events_actor_user_id_users_id_fk",
+ "tableFrom": "activity_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_events": {
+ "name": "audit_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_events_org_id_organizations_id_fk": {
+ "name": "audit_events_org_id_organizations_id_fk",
+ "tableFrom": "audit_events",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audit_events_actor_user_id_users_id_fk": {
+ "name": "audit_events_actor_user_id_users_id_fk",
+ "tableFrom": "audit_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "inbox_events": {
+ "name": "inbox_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message_key": {
+ "name": "message_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "inbox_events_source_message_key_unique": {
+ "name": "inbox_events_source_message_key_unique",
+ "columns": [
+ "source",
+ "message_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "jobs": {
+ "name": "jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "max_attempts": {
+ "name": "max_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "unique_key": {
+ "name": "unique_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "available_at": {
+ "name": "available_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "jobs_name_unique_key_unique": {
+ "name": "jobs_name_unique_key_unique",
+ "columns": [
+ "name",
+ "unique_key"
+ ],
+ "isUnique": true
+ },
+ "jobs_status_available_idx": {
+ "name": "jobs_status_available_idx",
+ "columns": [
+ "status",
+ "available_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "lockout_attempts": {
+ "name": "lockout_attempts",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "failures": {
+ "name": "failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_failure_at": {
+ "name": "first_failure_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_failure_at": {
+ "name": "last_failure_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "memberships": {
+ "name": "memberships",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "memberships_org_user_unique": {
+ "name": "memberships_org_user_unique",
+ "columns": [
+ "org_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "memberships_org_id_organizations_id_fk": {
+ "name": "memberships_org_id_organizations_id_fk",
+ "tableFrom": "memberships",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "memberships_user_id_users_id_fk": {
+ "name": "memberships_user_id_users_id_fk",
+ "tableFrom": "memberships",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "organizations": {
+ "name": "organizations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "organizations_slug_unique": {
+ "name": "organizations_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "outbox_events": {
+ "name": "outbox_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "topic": {
+ "name": "topic",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "max_attempts": {
+ "name": "max_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "available_at": {
+ "name": "available_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "outbox_events_idempotency_key_unique": {
+ "name": "outbox_events_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true,
+ "where": "\"outbox_events\".\"idempotency_key\" IS NOT NULL"
+ },
+ "outbox_events_status_available_idx": {
+ "name": "outbox_events_status_available_idx",
+ "columns": [
+ "status",
+ "available_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "projects_org_id_organizations_id_fk": {
+ "name": "projects_org_id_organizations_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "projects_created_by_users_id_fk": {
+ "name": "projects_created_by_users_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tasks": {
+ "name": "tasks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "assignee_id": {
+ "name": "assignee_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tasks_org_id_organizations_id_fk": {
+ "name": "tasks_org_id_organizations_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tasks_project_id_projects_id_fk": {
+ "name": "tasks_project_id_projects_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tasks_assignee_id_users_id_fk": {
+ "name": "tasks_assignee_id_users_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "assignee_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tasks_created_by_users_id_fk": {
+ "name": "tasks_created_by_users_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/database/migrations/meta/_journal.json b/src/database/migrations/meta/_journal.json
index f9ceb8b..ddd56a2 100644
--- a/src/database/migrations/meta/_journal.json
+++ b/src/database/migrations/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1783187120222,
"tag": "0004_loud_corsair",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "6",
+ "when": 1784415825690,
+ "tag": "0005_groovy_war_machine",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/database/schema/index.ts b/src/database/schema/index.ts
index 9e33302..40f2aac 100644
--- a/src/database/schema/index.ts
+++ b/src/database/schema/index.ts
@@ -2,6 +2,7 @@ import { activityEvents } from './activity';
import { auditEvents } from './audit-events';
import { inboxEvents } from './inbox-events';
import { jobs } from './jobs';
+import { lockoutAttempts } from './lockout-attempts';
import { memberships } from './memberships';
import { organizations } from './organizations';
import { outboxEvents } from './outbox-events';
@@ -20,6 +21,7 @@ export const schema = {
outboxEvents,
inboxEvents,
jobs,
+ lockoutAttempts,
};
export {
@@ -27,6 +29,7 @@ export {
auditEvents,
inboxEvents,
jobs,
+ lockoutAttempts,
memberships,
organizations,
outboxEvents,
@@ -40,6 +43,7 @@ export { INBOX_STATUSES } from './inbox-events';
export type { AuditEvent, NewAuditEvent } from './audit-events';
export type { Job, JobStatus, NewJob } from './jobs';
export { JOB_STATUSES } from './jobs';
+export type { LockoutAttempt, NewLockoutAttempt } from './lockout-attempts';
export type {
Membership,
MembershipRole,
diff --git a/src/database/schema/lockout-attempts.ts b/src/database/schema/lockout-attempts.ts
new file mode 100644
index 0000000..b3d9b4e
--- /dev/null
+++ b/src/database/schema/lockout-attempts.ts
@@ -0,0 +1,11 @@
+// The lockout counter table ships from @authlock/core so the app and the engine
+// reason about byte-identical DDL (key PK, failures, first_failure_at,
+// last_failure_at) — the same precedent as the jobs and messaging tables. The
+// engine's SqliteLockoutStore is constructed with this exact table instance
+// (see auth/lockout.setup.ts), so it is created once here and shared.
+import { sqliteLockoutTable } from '@authlock/core/sqlite';
+
+export const lockoutAttempts = sqliteLockoutTable('lockout_attempts');
+
+export type LockoutAttempt = typeof lockoutAttempts.$inferSelect;
+export type NewLockoutAttempt = typeof lockoutAttempts.$inferInsert;
diff --git a/src/trpc/trpc.module.ts b/src/trpc/trpc.module.ts
index adf5cf5..0c3d688 100644
--- a/src/trpc/trpc.module.ts
+++ b/src/trpc/trpc.module.ts
@@ -15,6 +15,8 @@ const env = loadEnv();
export interface AppTrpcContext {
authContext: AuthContext | null;
+ /** Source IP, exposed so the login procedure can key lockout on it. */
+ ip: string | undefined;
}
const logger = new Logger('Trpc');
@@ -34,6 +36,7 @@ const PUBLIC_CACHEABLE_QUERIES = new Set(['ping']);
autoSchemaFile: join(process.cwd(), 'src/@generated/server.ts'),
createContext: ({ req }: { req: AuthenticatedRequest }) => ({
authContext: req.authContext ?? null,
+ ip: req.ip ?? req.socket?.remoteAddress,
}),
// superjson (de)serializes every payload, so `Date` fields — e.g. the
diff --git a/test/e2e/auth-lockout.spec.ts b/test/e2e/auth-lockout.spec.ts
new file mode 100644
index 0000000..17ad37d
--- /dev/null
+++ b/test/e2e/auth-lockout.spec.ts
@@ -0,0 +1,86 @@
+import 'reflect-metadata';
+import { strict as assert } from 'node:assert';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { after, before, test } from 'node:test';
+import type { INestApplication } from '@nestjs/common';
+import { NestFactory } from '@nestjs/core';
+import superjson from 'superjson';
+import type { SuperJSONResult } from 'superjson';
+import { seedDatabase } from '../../scripts/seed';
+
+const trpcPath = '/trpc';
+const AUTH_SECRET = 'e2e-lockout-secret-must-be-at-least-32-characters';
+const LIMIT = 3;
+
+let app: INestApplication;
+let baseUrl: string;
+
+before(async () => {
+ const dbPath = join(
+ tmpdir(),
+ `nest-native-reference-app-e2e-lockout-${process.pid}-${Date.now()}.db`,
+ );
+ process.env.DATABASE_URL = dbPath;
+ process.env.TRPC_PATH = trpcPath;
+ process.env.AUTH_SECRET = AUTH_SECRET;
+ // Lock after a few failures so the test is fast and deterministic.
+ process.env.LOCKOUT_LIMIT = String(LIMIT);
+ process.env.LOCKOUT_COOLOFF_MS = String(15 * 60_000);
+
+ seedDatabase(dbPath);
+
+ const { AppModule } = await import('../../src/app.module');
+ app = await NestFactory.create(AppModule, { logger: false });
+ await app.listen(0, '127.0.0.1');
+ baseUrl = await app.getUrl();
+});
+
+after(async () => {
+ await app.close();
+});
+
+interface TrpcSuccess {
+ result?: unknown;
+ error?: SuperJSONResult;
+}
+
+/** Attempt a login and return the resulting HTTP status (200 on success). */
+async function loginStatus(email: string, password: string): Promise {
+ const response = await fetch(`${baseUrl}${trpcPath}/auth.login`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(superjson.serialize({ email, password })),
+ });
+ const body = (await response.json()) as TrpcSuccess;
+ if (body.result !== undefined) {
+ return 200;
+ }
+ const shape = superjson.deserialize<{ data: { httpStatus: number } }>(
+ body.error as SuperJSONResult,
+ );
+ return shape.data.httpStatus;
+}
+
+test('locks an identity after the failure limit and refuses even the correct password', async () => {
+ const email = 'admin@acme.test'; // the seeded admin
+
+ // The first LIMIT wrong-password attempts are rejected as invalid (401) and
+ // counted; the identity is not yet locked.
+ for (let attempt = 1; attempt <= LIMIT; attempt += 1) {
+ assert.equal(
+ await loginStatus(email, 'wrong-password'),
+ 401,
+ `attempt ${attempt} should be 401 (invalid credentials)`,
+ );
+ }
+
+ // Now locked: even the CORRECT password is refused with 429 Too Many Requests.
+ // This is the whole point — the brute-force control gates before the
+ // credential check.
+ assert.equal(
+ await loginStatus(email, 'admin123!'),
+ 429,
+ 'a locked identity is refused with 429 even with the right password',
+ );
+});