Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions GUIDELINES_NEST_REFERENCE_APP.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
# 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
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.
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
<p>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License" /></a>
<img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg" alt="Node version" />
<img src="https://img.shields.io/badge/libraries-7%2F7-0f766e.svg" alt="All seven nest-native libraries" />
<img src="https://img.shields.io/badge/libraries-8%2F8-0f766e.svg" alt="All eight nest-native libraries" />
</p>

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).

Expand All @@ -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 |
| --- | --- | --- |
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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
Expand Down
38 changes: 37 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/auth/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ export interface AuthContext {

export interface AuthenticatedRequest {
headers: Record<string, string | string[] | undefined>;
/** Connection address (populated by the HTTP platform). NOT a proxy header. */
ip?: string;
socket?: { remoteAddress?: string };
authContext?: AuthContext;
}
3 changes: 2 additions & 1 deletion src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/auth/auth.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ export class AuthRouter {
constructor(@Inject(AuthService) private readonly auth: AuthService) {}

@Mutation({ input: LoginInputSchema, output: LoginOutputSchema })
login(@Input() input: z.infer<typeof LoginInputSchema>) {
return this.auth.login(input);
login(
@Input() input: z.infer<typeof LoginInputSchema>,
@TrpcContext('ip') ip: string | undefined,
) {
return this.auth.login(input, ip);
}

@Query({ output: MeOutputSchema })
Expand Down
39 changes: 37 additions & 2 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<LoginResult> {
// 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()
Expand Down
40 changes: 40 additions & 0 deletions src/auth/lockout.setup.ts
Original file line number Diff line number Diff line change
@@ -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 {}
6 changes: 6 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions src/database/migrations/0005_groovy_war_machine.sql
Original file line number Diff line number Diff line change
@@ -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
);
Loading