Skip to content
Open
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
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Follow one journey through the code and every library shows up where a real syst
| Library | Its job in the story | Where in the code |
| --- | --- | --- |
| [`@nest-native/drizzle`](https://github.com/nest-native/drizzle) | **Persistence** — orgs, users, projects, tasks, activity; repositories, transactions, multi-tenant scoping | `src/database/`, every `*.repository.ts` (`@DrizzleRepository`, `@InjectTransaction`) |
| [`@nest-native/trpc`](https://github.com/nest-native/trpc) | **The typed API** — task CRUD, project queries, the activity feed, all typesafe end-to-end; the superjson transformer keeps the feed's `Date`s real across the wire (the client link is *required* to match, at compile time), and failed validations reach the client as flattened Zod field errors (`error.data.zodError`) | `src/modules/*/**.router.ts` (`@Router`, `@Query`/`@Mutation`), `src/trpc/` (transformer, error formatting, response meta), generated `AppRouter` |
| [`@nest-native/trpc`](https://github.com/nest-native/trpc) | **The typed API** — task CRUD, project queries, the activity feed, all typesafe end-to-end; the superjson transformer keeps the feed's `Date`s real across the wire (the client link is *required* to match, at compile time), and failed validations reach the client as flattened Zod field errors (`error.data.zodError`); Nest enhancers compose over procedures the usual way — `@UseGuards(AuthGuard, RolesGuard)` plus per-procedure `@Roles(...)` metadata | `src/modules/*/**.router.ts` (`@Router`, `@Query`/`@Mutation`), `src/trpc/` (transformer, error formatting, response meta), generated `AppRouter` |
| [`@nest-native/messaging`](https://github.com/nest-native/messaging) | **Reliable domain events** — the transactional outbox (emit in-tx) + idempotent inbox (dedup on consume) | `src/modules/{outbox,inbox,activity}/`, `OutboxProducer.enqueue` inside `@Transactional()` |
| [`@nest-native/kafka`](https://github.com/nest-native/kafka) | **The event backbone** — the outbox relays through `KafkaOutboxTransport`; `@KafkaConsumer`s build read-models | the Kafka profile in `src/app.module.ts`, `src/modules/inbox/*.consumer.ts` |
| [`@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 — **plus recurring work**: a DB-stored cron schedule drives the nightly stale-task sweep | `src/modules/reminders/`, `TaskAssignedProjection` in `src/modules/activity/`, `src/database/schema/jobs.ts` |
Expand All @@ -45,6 +45,43 @@ Everything above runs **with no infrastructure** by default:
- **In-process (default)** — the outbox relays through an in-process transport and handlers build the activity feed synchronously. SQLite in a file, no broker. This is what the tests exercise.
- **Kafka** — set `KAFKA_BROKERS` and the exact same domain code relays through `KafkaOutboxTransport` to a real cluster, with `@KafkaConsumer`s on the other side. The event bodies, dedup keys, and wire headers are identical; only the transport swaps.

## Auth, tenancy, and roles

Login mints an HS256 JWT that **snapshots one active organization** — the
caller's *oldest* membership (`created_at`, then `id` as the tiebreak, so
repeated logins always resolve the same tenant). The token carries no role.

- **Every guarded request re-checks the live membership.** `RolesGuard`
composes after `AuthGuard` and resolves the caller's membership in the
token's organization from the database — **reads included**, so revoking a
membership blocks the next request rather than leaving the member roster, the
project list, the activity feed and the token-spending AI assistant readable
until the token expires.
- **`@Roles(...)` narrows a procedure further.** `users.invite` is **admin**
only; `tasks.create` / `.assign` / `.complete` and `projects.create` accept
**admin or member**; a **viewer** reads only. Without `@Roles`, holding any
live membership is enough.
- **The token is a snapshot, never a permission.** It lives for
`AUTH_TTL_SECONDS` (default 3600 — an invalid value now fails at boot rather
than minting tokens that never verify) and names one organization; every
authorization decision is a fresh indexed lookup, deliberately uncached.
- **Tenancy is proven at the write.** Inside the same transaction,
`tasks.create` requires the project to belong to the caller's org and
`tasks.assign` requires the assignee to be a member of it. Both refuse with
exactly the error a nonexistent id gets, so the API is never a cross-tenant
existence oracle.
- **An invite creates a new account, never attaches an existing one.** Joining
an organization is the account owner's call, so an admin cannot pull another
tenant's user into their org (and then assign work to it); the refusal is the
same whether the address is already a member here or a stranger.

> **Password hashing is synchronous.** `src/auth/password.ts` uses `scryptSync`
> because a short, obviously-correct helper reads better in a reference app —
> but it blocks the event loop for every hash, so concurrent logins queue behind
> each other. Production adopters should move to an async hash or a worker pool;
> that complements `@nest-native/lockout` (which caps how many attempts reach the
> hash at all) rather than replacing it.

## Getting started

Requires **Node ≥ 22** (the AI SDK requires it).
Expand Down Expand Up @@ -93,7 +130,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; @nest-native/lockout login lockout (lockout.setup.ts)
auth/ scrypt passwords, HS256 JWT, AuthGuard + RolesGuard/@Roles, middleware; @nest-native/lockout login lockout (lockout.setup.ts)
cache/ @nest-native/cache read caching — tag invalidation through @stalefree/core (cache.setup.ts)
context/ request-scoped CURRENT_USER / CURRENT_ORGANIZATION
modules/
Expand All @@ -120,7 +157,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, the AI stream, and the login-lockout gate (fail N times → 429, even the right password is refused while locked), and cache coherence (mutations refresh cached reads long before TTL — tag invalidation, not expiry) 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), and cache coherence (mutations refresh cached reads long before TTL — tag invalidation, not expiry) all have explicit tests, as do the tenancy and role checks (cross-org project/assignee refused like a missing one with nothing committed; viewer/member/admin limits and revocation over real HTTP). CI runs on **Node 22**.

Two **optional, local-only** layers sit on top (neither runs in CI, and forks
work without them):
Expand Down
86 changes: 80 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ service deps through `@Inject(...)` in the constructor and call them.
│ - or - │
│ tRPC handler │ ←── nest-trpc-native dispatch
│ AuthGuard │ reads ctx.authContext via getArgs()[1]
│ RolesGuard │ re-reads the caller's membership row
│ │ (every guarded request — see Authorization)
│ ParamDecorators │ @Input, @TrpcContext, @CurrentUser
│ Procedure body │
└──────────────────────┘
Expand All @@ -176,10 +178,14 @@ serves both transports.
## Authentication

`AuthService.login(email, password)` runs `scrypt`-verify against the stored
hash, finds the first membership row for the user, and mints a real
HS256-signed JWT containing `{ sub: userId, org: orgId, iat, exp }`. The
signing key comes from `AUTH_SECRET` (min 32 chars, required in production,
deterministic dev fallback elsewhere).
hash, picks the user's **oldest** membership (ordered by `created_at`, then
`id` as the tiebreak — so repeated logins always land on the same tenant), and
mints a real HS256-signed JWT containing
`{ sub: userId, org: orgId, iat, exp }`. The signing key comes from
`AUTH_SECRET` (min 32 chars, required in production, deterministic dev fallback
elsewhere) and the lifetime from `AUTH_TTL_SECONDS` (default 3600; a NaN, zero
or negative value fails `loadEnv()` at boot instead of minting tokens that can
never verify).

JWT verification uses Node's built-in `node:crypto` HMAC — there's no JWT
library dependency. See
Expand All @@ -190,6 +196,71 @@ covers roundtrip, tamper, expiry, wrong-secret, malformed, and unsupported-algor
Password hashing is `scrypt` with a 16-byte random salt; format is
`scrypt$<salt-hex>$<hash-hex>`. The same helpers are reused by
[`scripts/seed.ts`](https://github.com/nest-native/reference-app/blob/main/scripts/seed.ts) so seeded users can log in.
It is deliberately the **synchronous** `scryptSync` — a short, obviously-correct
helper reads better here — but that blocks the event loop for the duration of
every hash, so concurrent logins queue behind each other. A production adopter
should swap in an async hash or a worker pool; that is orthogonal to (not a
replacement for) the login lockout below, which limits how many attempts reach
the hash at all.

## Authorization (roles + tenancy)

The token says **who** is calling and **which** organization is active. It
deliberately says nothing about what the caller may do — that is re-read from
the database on every guarded request:

```
@Router('tasks')
@UseGuards(AuthGuard, RolesGuard) ← composed left to right
export class TasksRouter {
@Query(...) list(...) ← no @Roles: any live member may read
@Roles('admin', 'member')
@Mutation(...) create(...) ← RolesGuard re-reads the membership row
}
```

[`RolesGuard`](https://github.com/nest-native/reference-app/blob/main/src/auth/roles.guard.ts)
resolves `MembershipsRepository.findByOrgAndUser(activeOrg, caller)` at request
time and throws `ForbiddenException` when the membership is missing (revoked)
or its role is not in the procedure's `@Roles(...)` list. The policy is
deliberately small — three roles, no policy engine, no per-resource ACLs:
`users.invite` is `admin` only; `tasks.create` / `.assign` / `.complete` and
`projects.create` accept `admin` or `member`; `viewer` reads only.

**Reads are guarded too.** A procedure without `@Roles` still needs a live
membership: the guard is on every tenant-scoped router (`tasks`, `projects`,
`users`, `organizations`, `activity`) and on the assistant controller, so a
revoked account loses the member roster, the project list, the activity feed and
the token-spending AI digest on its *next* request instead of keeping them for
up to `AUTH_TTL_SECONDS`. The cost is one indexed lookup per request; it is
deliberately not cached, because a stale allow is exactly the failure being
prevented. `auth.me` is the exception — it only echoes the token back.

Because the guard's dependency has to travel with the guard, the single
`DrizzleModule.forFeature([MembershipsRepository])` registration lives in
[`MembershipsModule`](https://github.com/nest-native/reference-app/blob/main/src/modules/memberships/memberships.module.ts),
which `AuthModule` imports **and re-exports**. (`forFeature()` returns a new
dynamic module object per call and Nest keys modules by identity, so that one
call is hoisted into a constant and reused by `imports` and `exports`.)

Tenancy is proven at the write, not assumed from the token. Inside the same
transaction that writes the row, `TasksService.createTask` requires the
`projectId` to resolve *within the caller's org* and `assignTask` requires the
assignee to hold a membership *in that org*. Both refuse with exactly the error
a nonexistent id gets (`Project 42 not found` /
`User 42 is not a member of this organization`) — a distinct "belongs to
someone else" message would turn the API into a cross-tenant existence oracle.
Membership itself is only ever granted to a **new** account:
`OrganizationOnboardingService` refuses an invite whose email already has one,
so an admin cannot attach a stranger — or another tenant's admin — to their
organization without consent, and cannot manufacture an assignee that way. The
refusal reads the same for an address that is already a member here and for one
that belongs to another tenant; erasing the last signal (that an account exists
at all) needs a pending-invitation row the invitee accepts, which is the shape a
production app should reach for.

See [`test/integration/tenant-authz.spec.ts`](https://github.com/nest-native/reference-app/blob/main/test/integration/tenant-authz.spec.ts)
and [`test/e2e/roles-authz.spec.ts`](https://github.com/nest-native/reference-app/blob/main/test/e2e/roles-authz.spec.ts).

## Login lockout

Expand Down Expand Up @@ -333,13 +404,13 @@ same image (see `docker-compose.yml`).
| `app.module.ts` | Root module: imports + ClsPluginTransactional wiring |
| `config/env.ts` | `loadEnv()` — single source of truth for env vars |
| `database/` | `DatabaseModule` (DrizzleModule.forRoot wiring), schema, migrations |
| `auth/` | JWT helpers, scrypt password helpers, `AuthService`, middleware, `AuthGuard`, `@CurrentUser`/`@CurrentOrganization` decorators, `AuthRouter` |
| `auth/` | JWT helpers, scrypt password helpers, `AuthService`, middleware, `AuthGuard` + `RolesGuard`/`@Roles`, `@CurrentUser`/`@CurrentOrganization` decorators, `AuthRouter` |
| `context/` | `RequestContextModule` — Nest request-scoped `CURRENT_USER` / `CURRENT_ORGANIZATION` providers backed by `req.authContext` |
| `health/` | `/health` REST controller |
| `modules/organizations/` | Repo + service + tRPC router. `organizations.current` / `.list` |
| `modules/users/` | Repo + service + tRPC router. `users.me` / `.list` / `.invite` |
| `modules/projects/` | Repo + service + tRPC router. `projects.list` / `.get` / `.create` |
| `modules/memberships/` | Repo only (consumed by onboarding) |
| `modules/memberships/` | Repo + the one `forFeature` registration of it, re-exported through `AuthModule` (consumed by onboarding, `RolesGuard`, and the task tenancy checks) |
| `modules/audit-log/` | `AuditLogService.record()` |
| `modules/outbox/` | Producer, claimer, registry, fake transport, `user.invited` handler, `outbox.constants.ts` |
| `modules/onboarding/` | `OrganizationOnboardingService` — the `@Transactional` workflow |
Expand All @@ -361,6 +432,9 @@ same image (see `docker-compose.yml`).
| `test/e2e/auth-flow.spec.ts` | Login flow over real HTTP; 401 on wrong password / no token / bad token |
| `test/e2e/trpc-ping.smoke.spec.ts` | `GET /trpc/ping` returns 'pong'; `/health` returns ok |
| `test/e2e/core-modules.spec.ts` | Authenticated flow over real HTTP across the three core routers |
| `test/integration/tenant-authz.spec.ts` | Cross-org project/assignee are refused like missing ones, nothing committed; an invite cannot attach an existing account; login picks the oldest membership |
| `test/e2e/roles-authz.spec.ts` | RBAC over real HTTP: viewer/member/admin limits, and a revoked membership blocking the next request — reads and the AI assistant included |
| `test/integration/auth-context.spec.ts` | The guards' caller extractor: tRPC reads the procedure context, never the caller's input |

Plus `client-smoke/client.ts` (typed client over real HTTP using the
generated `AppRouter`) which is run via `npm run client-smoke` rather than
Expand Down
9 changes: 9 additions & 0 deletions scripts/start-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ async function main(): Promise<void> {
`worker started (outbox + jobs): db=${env.databaseUrl} poll=${env.outbox.pollIntervalMs}ms batch=${env.outbox.batchSize} stuck=${env.outbox.stuckTimeoutMs}ms`,
);

// The worker writes read-model rows (activity feed) the API process caches.
// Without the shared bus its tag invalidations never leave this process, so
// the API keeps serving stale reads until each entry's TTL lapses.
if (!env.cacheSocketPath) {
logger.warn(
`CACHE_SOCKET_PATH is unset: cross-process cache invalidation is OFF, so API reads can stay stale for up to CACHE_TTL_MS (${env.cacheTtlMs}ms). Set CACHE_SOCKET_PATH to the same socket path in both processes.`,
);
}

const reportTick = (
loop: string,
report: { claimed: number; completed: number; retried: number; failed: number },
Expand Down
29 changes: 29 additions & 0 deletions src/auth/auth-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ExecutionContext } from '@nestjs/common';

export interface CurrentUserContext {
id: number;
email?: string;
Expand All @@ -20,3 +22,30 @@ export interface AuthenticatedRequest {
socket?: { remoteAddress?: string };
authContext?: AuthContext;
}

/**
* One extractor for both transports: tRPC passes its context object as the
* second handler argument (`getArgs()[1]`), Express carries it on the request.
* Shared by AuthGuard and RolesGuard so they never disagree about the caller.
*
* It BRANCHES on the transport rather than trying one then the other, because
* `switchToHttp().getRequest()` is just `getArgs()[0]` whatever the transport
* is — under tRPC that argument is the caller's own INPUT, so a fallback would
* read authentication out of the request body. Zod strips unknown keys, so no
* procedure here can be forged today; a single `.passthrough()` schema is all
* it would take, and an auth extractor must not depend on that.
*/
export function readAuthContext(
context: ExecutionContext,
): AuthContext | undefined {
if (context.getType() === 'http') {
const req = context.switchToHttp().getRequest<
AuthenticatedRequest | undefined
>();
return req?.authContext;
}
const trpcCtx = context.getArgs()[1] as
| { authContext?: AuthContext }
| undefined;
return trpcCtx?.authContext;
}
18 changes: 2 additions & 16 deletions src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,14 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { AuthContext, AuthenticatedRequest } from './auth-context';
import { readAuthContext } from './auth-context';

@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (!this.extractAuthContext(context)?.user) {
if (!readAuthContext(context)?.user) {
throw new UnauthorizedException();
}
return true;
}

private extractAuthContext(
context: ExecutionContext,
): AuthContext | undefined {
const trpcCtx = context.getArgs()[1] as
| { authContext?: AuthContext }
| undefined;
if (trpcCtx?.authContext) return trpcCtx.authContext;

const req = context.switchToHttp().getRequest<
AuthenticatedRequest | undefined
>();
return req?.authContext;
}
}
Loading