# Phase 1 — Tenants, JWT Auth, Audit Log > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Introduce multi-tenancy and JWT-based admin authentication across the entire TOWER stack. Every existing model, endpoint, and queue payload is retrofitted to be tenant-scoped. An `AuditEvent` log captures every privileged action. The web app gains a `/login` page and a bearer-token cookie session. **Architecture:** All Prisma models gain a non-nullable `tenantId` foreign key. The API mounts a global `JwtAuthGuard` that decodes the bearer token, populates `request.tenantContext`, and rejects unauthenticated requests (with `@Public()` opt-out for `/health`). The web app stores the JWT in an HTTP-only `tower_token` cookie and forwards it on the server side via an `apiFetch` helper. New `Auth` and `Audit` modules live under `apps/api/src/modules/`. **Tech Stack:** NestJS 11, Passport JWT, bcryptjs (pure JS, no native binding), Prisma 6, Next.js 16, React 19, Jest 29, TypeScript 5.7. --- ## File Map **Created** ``` apps/api/prisma/seed.ts # default tenant + OWNER admin apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql # hand-edited for backfill apps/api/src/common/tenant-context.ts apps/api/src/modules/auth/password.util.ts # bcryptjs hash/verify apps/api/src/modules/auth/password.util.spec.ts apps/api/src/modules/auth/public.decorator.ts # @Public() apps/api/src/modules/auth/current-admin.decorator.ts # @CurrentAdmin() apps/api/src/modules/auth/current-tenant.decorator.ts # @CurrentTenantContext() apps/api/src/modules/auth/roles.decorator.ts # @Roles(...) apps/api/src/modules/auth/jwt.strategy.ts apps/api/src/modules/auth/jwt-auth.guard.ts apps/api/src/modules/auth/roles.guard.ts apps/api/src/modules/auth/auth.service.ts apps/api/src/modules/auth/auth.service.spec.ts apps/api/src/modules/auth/auth.controller.ts apps/api/src/modules/auth/auth.module.ts apps/api/src/modules/auth/dto/login.dto.ts apps/api/src/modules/audit/audit.types.ts # AuditAction constants apps/api/src/modules/audit/audit.service.ts apps/api/src/modules/audit/audit.service.spec.ts apps/api/src/modules/audit/audit.module.ts # @Global() packages/types/src/auth.ts # AdminRole, JwtPayload, LoginRequest/Response apps/web/app/_lib/api.ts # server-side apiFetch with bearer apps/web/app/_lib/auth-context.tsx # client AuthProvider apps/web/app/_lib/auth-context.test.tsx apps/web/app/_lib/sidebar.tsx # auth-aware nav + Sign out apps/web/app/login/page.tsx apps/web/app/login/page.test.tsx apps/web/app/api/auth/login/route.ts apps/web/app/api/auth/logout/route.ts apps/web/app/api/auth/me/route.ts ``` **Modified** ``` apps/api/prisma/schema.prisma # Tenant, Admin, AuditEvent, tenantId on 6 models apps/api/package.json # bcryptjs, passport-jwt, @nestjs/jwt/passport, db:seed apps/api/src/main.ts # global ValidationPipe + global JwtAuthGuard apps/api/src/app.module.ts # imports AuthModule, AuditModule apps/api/src/modules/health/health.controller.ts # @Public() apps/api/src/modules/groups/{controller,service,*.spec}.ts # tenant filter apps/api/src/modules/routes/{controller,service,*.spec}.ts # tenant filter + audit apps/api/src/modules/accounts/{controller,service,*.spec,module}.ts # tenant filter + audit + @Roles on create apps/api/src/modules/search/{controller,service,*.spec}.ts # tenant filter apps/api/src/prisma/prisma.service.spec.ts # backfill tenant on test data packages/types/src/message.ts # tenantId on IngestJobData/ForwardJobData/IndexJobData packages/types/src/index.ts # re-export ./auth packages/config/src/index.ts # BCRYPT_ROUNDS, JWT_EXPIRES_IN packages/search/src/index.ts # MeiliDocument.tenantId; configureIndex adds filterable packages/search/src/index.test.ts # tenantId in fixture + filterable assertion apps/worker/src/main.ts # tenantId on startAccount + ingest job apps/worker/src/queues/ingest.processor.ts + .test.ts # writes tenantId to Message apps/worker/src/queues/index.processor.ts + .test.ts # writes tenantId to MeiliDocument apps/worker/src/queues/forward.processor.test.ts # tenantId in baseJob apps/worker/src/core/approval.ts # propagates tenantId to Approval/forwardJobs/indexDoc apps/worker/src/whatsapp/group-sync.ts # resolves tenantId from Account apps/web/app/api/accounts/route.ts # uses apiFetch apps/web/app/api/accounts/[id]/qr/route.ts # uses apiFetch apps/web/app/api/routes/route.ts # uses apiFetch apps/web/app/api/routes/[id]/route.ts # uses apiFetch apps/web/app/layout.tsx # AuthProvider + Sidebar .env.example # BCRYPT_ROUNDS, JWT_EXPIRES_IN, SEED_ADMIN_* ``` --- ## Tasks ### Task 1: Add Tenant model + tenantId on every existing model - [x] Add `Tenant { id, slug (unique), name, createdAt, updatedAt }` to schema - [x] Add non-nullable `tenantId` FK on Group, Message, Approval, SyncRoute, ConsentRecord, Account - [x] Hand-edit the auto-generated migration to: create Tenant, INSERT default tenant, ADD COLUMN nullable, UPDATE backfill, SET NOT NULL, add FK - [x] Apply migration; verify existing rows have `tenantId = ''` ### Task 2: Add Admin model + password util - [x] `Admin { id, tenantId, email (unique per tenant), name, role: AdminRole, passwordHash, createdAt, updatedAt }` + `AdminRole { OWNER, ADMIN, VIEWER }` enum - [x] `password.util.ts` exporting `hashPassword(plain, rounds)` and `verifyPassword(plain, hash)` using `bcryptjs` - [x] `password.util.spec.ts` (mocks bcryptjs) ### Task 3: Add AuditEvent model + AuditService - [x] `AuditEvent { id, tenantId, actorType: ActorType, actorId?, action, resourceType, resourceId, payload (Json), traceId?, createdAt }` + `ActorType { ADMIN, SYSTEM, WORKER }` - [x] `audit.types.ts` exporting `AuditAction` constants (`AUTH_LOGIN`, `AUTH_LOGOUT`, `ROUTE_CREATED`, `ROUTE_DELETED`, `ACCOUNT_CREATED`, etc.) - [x] `AuditService` with `.log({ action, resourceType, resourceId, actorType?, actorId?, payload?, traceId?, tenantId? })` - Throws if no `tenantId` can be resolved (from input override or injected `TenantContext`) - [x] `AuditModule` registered as `@Global()` ### Task 4: TenantContext type - [x] `apps/api/src/common/tenant-context.ts` exporting `interface TenantContext { tenantId: string; adminId: string; role: AdminRole }` ### Task 5: Auth guards, strategy, decorators - [x] `JwtStrategy` (passport-jwt) — `validate()` returns `{ sub, email, tenantId, role }` - [x] `JwtAuthGuard` extends `AuthGuard('jwt')` — checks `@Public()` via Reflector; `handleRequest` populates `request.tenantContext` - [x] `RolesGuard` reads `@Roles(...)` metadata; throws `ForbiddenException` on mismatch - [x] `@Public()`, `@CurrentAdmin()`, `@CurrentTenantContext()`, `@Roles(... @Input)` decorators ### Task 6: AuthModule + endpoints - [x] `AuthService.login(email, password)` — finds Admin, verifies hash, signs JWT, returns `{ token, admin }`, writes `AUTH_LOGIN` audit - [x] `AuthService.me(adminId)` — returns admin profile (with tenant) - [x] `AuthService.logout(adminId)` — writes `AUTH_LOGOUT` audit - [x] `AuthController`: `POST /auth/login` (public), `POST /auth/logout` (auth), `GET /auth/me` (auth) - [x] `LoginDto` with `class-validator` (`@IsEmail`, `@IsString`, `@MinLength`) - [x] `auth.service.spec.ts` ### Task 7: Wire global guards in main.ts - [x] `app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))` - [x] `app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))` - [x] `app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)))` - [x] Mark `HealthController` with `@Public()` ### Task 8: Retrofit existing controllers/services - [x] Groups: read `@CurrentTenantContext()`, filter by `tenantId` - [x] Routes: read `@CurrentTenantContext()`, filter by `tenantId`, write `ROUTE_CREATED` / `ROUTE_DELETED` audit - [x] Accounts: read `@CurrentTenantContext()`, filter by `tenantId`, write `ACCOUNT_CREATED` audit, gate `POST` with `@Roles('OWNER', 'ADMIN')` - [x] Search: ALWAYS filter Meilisearch query by `tenantId` (non-negotiable) - [x] Updated spec files for all four ### Task 9: Seed script - [x] `apps/api/prisma/seed.ts` upserts `'default'` tenant and an OWNER admin from `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` env vars - [x] `db:seed` script in `apps/api/package.json` - [x] `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` in `.env.example` ### Task 10: MeiliDocument tenantId - [x] `MeiliDocument.tenantId: string` - [x] `configureIndex` adds `tenantId` to `filterableAttributes` - [x] `index.test.ts` asserts `tenantId` in filterable list ### Task 11: Tenant propagation through the worker - [x] `IngestJobData.tenantId`, `ForwardJobData.tenantId`, `IndexJobData.tenantId` - [x] `index.processor.ts` writes `tenantId` to `MeiliDocument` - [x] `ingest.processor.ts` writes `tenantId` to `Message` - [x] `group-sync.ts` resolves `tenantId` from the `Account` record before upserting groups - [x] `approval.ts` propagates `tenantId` to `Approval`, `forwardJobs`, and `indexDoc` - [x] `worker/main.ts` includes `tenantId` in `startAccount` and in the ingest job data ### Task 12: Web login page + AuthProvider - [x] `apps/web/app/_lib/auth-context.tsx` — `AuthProvider` + `useAuth()` exposing `{ admin, loading, error, refresh, logout }` - [x] `apps/web/app/login/page.tsx` — form posting to `/api/auth/login`, redirects to `?next=…` on success - Split into `LoginForm` wrapped in `` (required by `useSearchParams` in static gen) - [x] `auth-context.test.tsx` (3 tests) + `login/page.test.tsx` (3 tests) ### Task 13: Web auth API routes + apiFetch - [x] `apps/web/app/_lib/api.ts` — `apiFetch(path, init)` reads `tower_token` cookie, sets `Authorization: Bearer …`, forwards to `API_URL` - [x] `apps/web/app/api/auth/login/route.ts` — POST credentials to API, on success sets HTTP-only `tower_token` cookie - [x] `apps/web/app/api/auth/logout/route.ts` — POST clears cookie - [x] `apps/web/app/api/auth/me/route.ts` — GET forwards to API - [x] Retrofit existing route handlers (`/api/accounts`, `/api/accounts/[id]/qr`, `/api/routes`, `/api/routes/[id]`) to use `apiFetch` ### Task 14: Config + env - [x] `BCRYPT_ROUNDS` (default 10), `JWT_EXPIRES_IN` (default `7d`) in `@tower/config` - [x] `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` in `.env.example` ### Task 15: Verification - [x] `pnpm turbo build` — green across all 15 packages - [x] `pnpm turbo test` — 13 API suites (58 tests) + 10 worker suites (68 tests) + 7 web suites (34 tests) all green - [x] Seed ran: `admin@tower.local` / `OWNER` on `default` tenant - [x] Migration backfilled 99 groups, 3 messages, 1 approval, 1 sync route, 1 account to `default` tenant - [x] Sidebar shows admin name, tenant, role, and Sign out button; redirects to `/login?next=…` when unauthenticated - [x] This plan doc written to `docs/superpowers/plans/2026-06-02-phase1-tenants-auth-audit.md` --- ## Notes / Gotchas - **bcrypt 5.x native binding** fails to load in the pnpm environment. Switched to **`bcryptjs`** (pure JS, identical API). - **Prisma migration backfill**: Prisma's default migration can't add a non-nullable FK to a table that already has rows. Hand-edited the generated `migration.sql` to: create Tenant → INSERT default → ADD COLUMN nullable → UPDATE backfill → SET NOT NULL → add FK. - **JWT_EXPIRES_IN typing**: `string` is not assignable to `@nestjs/jwt`'s strict `StringValue` template literal type. Cast at the call site: `process.env['JWT_EXPIRES_IN'] as \`${number}d\` | ...`. - **passport-jwt user cast**: TypeScript needs `user as unknown as JwtPayload` in `JwtAuthGuard.handleRequest` because the generic `TUser` doesn't structurally overlap with `JwtPayload`. - **bcryptjs mock**: `jest.mock('bcryptjs')` alone doesn't expose `jest.fn()`s. Use a factory: `jest.mock('bcryptjs', () => ({ __esModule: true, hash: jest.fn(), compare: jest.fn() }))`. - **`useSearchParams` static gen**: requires a `` boundary around the component that uses it. Split `LoginPage` into `LoginPage` (shell) and `LoginForm` (inner, wrapped in Suspense). - **Worker is uncommitted-modification territory**: `selfJid` handling in `normalizer.ts` was changed recently. The retrofit was careful not to touch that file or behavior.