14 KiB
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
- Add
Tenant { id, slug (unique), name, createdAt, updatedAt }to schema - Add non-nullable
tenantIdFK on Group, Message, Approval, SyncRoute, ConsentRecord, Account - Hand-edit the auto-generated migration to: create Tenant, INSERT default tenant, ADD COLUMN nullable, UPDATE backfill, SET NOT NULL, add FK
- Apply migration; verify existing rows have
tenantId = '<default tenant id>'
Task 2: Add Admin model + password util
Admin { id, tenantId, email (unique per tenant), name, role: AdminRole, passwordHash, createdAt, updatedAt }+AdminRole { OWNER, ADMIN, VIEWER }enumpassword.util.tsexportinghashPassword(plain, rounds)andverifyPassword(plain, hash)usingbcryptjspassword.util.spec.ts(mocks bcryptjs)
Task 3: Add AuditEvent model + AuditService
AuditEvent { id, tenantId, actorType: ActorType, actorId?, action, resourceType, resourceId, payload (Json), traceId?, createdAt }+ActorType { ADMIN, SYSTEM, WORKER }audit.types.tsexportingAuditActionconstants (AUTH_LOGIN,AUTH_LOGOUT,ROUTE_CREATED,ROUTE_DELETED,ACCOUNT_CREATED, etc.)AuditServicewith.log({ action, resourceType, resourceId, actorType?, actorId?, payload?, traceId?, tenantId? })- Throws if no
tenantIdcan be resolved (from input override or injectedTenantContext)
- Throws if no
AuditModuleregistered as@Global()
Task 4: TenantContext type
apps/api/src/common/tenant-context.tsexportinginterface TenantContext { tenantId: string; adminId: string; role: AdminRole }
Task 5: Auth guards, strategy, decorators
JwtStrategy(passport-jwt) —validate()returns{ sub, email, tenantId, role }JwtAuthGuardextendsAuthGuard('jwt')— checks@Public()via Reflector;handleRequestpopulatesrequest.tenantContextRolesGuardreads@Roles(...)metadata; throwsForbiddenExceptionon mismatch@Public(),@CurrentAdmin(),@CurrentTenantContext(),@Roles(... @Input)decorators
Task 6: AuthModule + endpoints
AuthService.login(email, password)— finds Admin, verifies hash, signs JWT, returns{ token, admin }, writesAUTH_LOGINauditAuthService.me(adminId)— returns admin profile (with tenant)AuthService.logout(adminId)— writesAUTH_LOGOUTauditAuthController:POST /auth/login(public),POST /auth/logout(auth),GET /auth/me(auth)LoginDtowithclass-validator(@IsEmail,@IsString,@MinLength)auth.service.spec.ts
Task 7: Wire global guards in main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)))- Mark
HealthControllerwith@Public()
Task 8: Retrofit existing controllers/services
- Groups: read
@CurrentTenantContext(), filter bytenantId - Routes: read
@CurrentTenantContext(), filter bytenantId, writeROUTE_CREATED/ROUTE_DELETEDaudit - Accounts: read
@CurrentTenantContext(), filter bytenantId, writeACCOUNT_CREATEDaudit, gatePOSTwith@Roles('OWNER', 'ADMIN') - Search: ALWAYS filter Meilisearch query by
tenantId(non-negotiable) - Updated spec files for all four
Task 9: Seed script
apps/api/prisma/seed.tsupserts'default'tenant and an OWNER admin fromSEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORDenv varsdb:seedscript inapps/api/package.jsonSEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORDin.env.example
Task 10: MeiliDocument tenantId
MeiliDocument.tenantId: stringconfigureIndexaddstenantIdtofilterableAttributesindex.test.tsassertstenantIdin filterable list
Task 11: Tenant propagation through the worker
IngestJobData.tenantId,ForwardJobData.tenantId,IndexJobData.tenantIdindex.processor.tswritestenantIdtoMeiliDocumentingest.processor.tswritestenantIdtoMessagegroup-sync.tsresolvestenantIdfrom theAccountrecord before upserting groupsapproval.tspropagatestenantIdtoApproval,forwardJobs, andindexDocworker/main.tsincludestenantIdinstartAccountand in the ingest job data
Task 12: Web login page + AuthProvider
apps/web/app/_lib/auth-context.tsx—AuthProvider+useAuth()exposing{ admin, loading, error, refresh, logout }apps/web/app/login/page.tsx— form posting to/api/auth/login, redirects to?next=…on success- Split into
LoginFormwrapped in<Suspense>(required byuseSearchParamsin static gen)
- Split into
auth-context.test.tsx(3 tests) +login/page.test.tsx(3 tests)
Task 13: Web auth API routes + apiFetch
apps/web/app/_lib/api.ts—apiFetch(path, init)readstower_tokencookie, setsAuthorization: Bearer …, forwards toAPI_URLapps/web/app/api/auth/login/route.ts— POST credentials to API, on success sets HTTP-onlytower_tokencookieapps/web/app/api/auth/logout/route.ts— POST clears cookieapps/web/app/api/auth/me/route.ts— GET forwards to API- Retrofit existing route handlers (
/api/accounts,/api/accounts/[id]/qr,/api/routes,/api/routes/[id]) to useapiFetch
Task 14: Config + env
BCRYPT_ROUNDS(default 10),JWT_EXPIRES_IN(default7d) in@tower/configSEED_ADMIN_EMAIL,SEED_ADMIN_PASSWORDin.env.example
Task 15: Verification
pnpm turbo build— green across all 15 packagespnpm turbo test— 13 API suites (58 tests) + 10 worker suites (68 tests) + 7 web suites (34 tests) all green- Seed ran:
admin@tower.local/OWNERondefaulttenant - Migration backfilled 99 groups, 3 messages, 1 approval, 1 sync route, 1 account to
defaulttenant - Sidebar shows admin name, tenant, role, and Sign out button; redirects to
/login?next=…when unauthenticated - 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.sqlto: create Tenant → INSERT default → ADD COLUMN nullable → UPDATE backfill → SET NOT NULL → add FK. - JWT_EXPIRES_IN typing:
stringis not assignable to@nestjs/jwt's strictStringValuetemplate 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 JwtPayloadinJwtAuthGuard.handleRequestbecause the genericTUserdoesn't structurally overlap withJwtPayload. - bcryptjs mock:
jest.mock('bcryptjs')alone doesn't exposejest.fn()s. Use a factory:jest.mock('bcryptjs', () => ({ __esModule: true, hash: jest.fn(), compare: jest.fn() })). useSearchParamsstatic gen: requires a<Suspense>boundary around the component that uses it. SplitLoginPageintoLoginPage(shell) andLoginForm(inner, wrapped in Suspense).- Worker is uncommitted-modification territory:
selfJidhandling innormalizer.tswas changed recently. The retrofit was careful not to touch that file or behavior.