good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
# 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 = '<default tenant id>'`
### 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 `<Suspense>` (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 `<Suspense>` 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.
@@ -0,0 +1,268 @@
# Phase 2B — Bot-Number Model (shared, hidden, multi-tenant-ready)
**Date**: 2026-06-04
**Status**: In progress
**Supersedes**: Self-scan account model (current `Account` rows + `POST /accounts`)
## Goals
1. Migrate TOWER from self-scan (admin pairs personal WhatsApp) to a dedicated **bot-number** model.
2. Bot is **shared** across all tenants. Schema is multi-bot-ready but ships with one bot.
3. Bot's phone number is **hidden** from public web surfaces. Admins see it in `/settings/bot` behind a reveal toggle.
4. **Hard-delete** all data from self-scan era. Preserve `Tenant`, `Admin`, pre-migration `AuditEvent`.
5. Add **member onboarding** so group members become first-class TOWER users with `/my` portal + opt-out.
## Locked decisions
| Decision | Choice |
|---|---|
| Which tenants get claim notifications | All OWNERs of all tenants |
| Unclaimed group TTL (7 days) | Mark `EXPIRED`, bot stops listening |
| Welcome message timing | Generic intro on join, full onboarding link after claim |
| Migration of tenants/admins | Preserve `Tenant` + `Admin`; truncate data |
## 1. Schema changes
```prisma
model Account {
id String @id @default(cuid())
platform String
jid String
sessionPath String
displayName String?
status AccountStatus @default(ACTIVE)
qrCode String?
isBot Boolean @default(true)
pairingToken String? @unique
pairingExpiresAt DateTime?
tenants TenantBot[]
groups Group[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([platform, jid])
@@index([isBot])
}
model TenantBot {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@unique([tenantId, accountId])
@@index([accountId])
}
model Group {
id String @id @default(cuid())
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id])
platform String
platformId String
name String
description String?
isActive Boolean @default(true)
accountId String?
account Account? @relation(fields: [accountId], references: [id])
claimStatus GroupClaimStatus @default(PENDING_CLAIM)
claimedAt DateTime?
claimedByAdminId String?
claimExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
syncRoutesTo SyncRoute[] @relation("targetGroup")
consents ConsentRecord[]
@@unique([platform, platformId])
@@index([accountId])
@@index([tenantId])
@@index([claimStatus])
}
enum GroupClaimStatus {
PENDING_CLAIM
CLAIMED
RELEASED
EXPIRED
}
enum ConsentScope { INGEST ARCHIVE REPLICATE DISPLAY }
enum ConsentStatus { GRANTED REVOKED }
enum MemberOptOutReason { STOP_KEYWORD SELF_PORTAL ADMIN_ACTION }
model TowerUser {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
phoneHash String
jid String
displayName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
consents ConsentRecord[]
optOuts MemberOptOut[]
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@index([tenantId])
}
model TowerSession {
id String @id @default(cuid())
userId String
user TowerUser @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
model ConsentRecord {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
groupId String
group Group @relation(fields: [groupId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
scopes ConsentScope[]
retentionDays Int @default(90)
policyVersion String
status ConsentStatus @default(GRANTED)
proofEventId String
effectiveAt DateTime @default(now())
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([tenantId, groupId, userId])
@@index([status])
}
model MemberOptOut {
id String @id @default(cuid())
tenantId String
userId String
user TowerUser @relation(fields: [userId], references: [id])
groupId String?
reason MemberOptOutReason
notes String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([groupId])
}
```
`Message` gets `senderTowerUserId String?` + `senderTowerUser TowerUser? @relation(...)`.
`Tenant` back-relations: `tenantBots TenantBot[]`, `towerUsers TowerUser[]`, `consentRecords ConsentRecord[]` (already), `memberOptOuts MemberOptOut[]`.
## 2. Migration
`apps/api/prisma/migrations/<ts>_phase2b_bot_claim/migration.sql`:
```sql
-- Hard delete data; preserve Tenant, Admin, AuditEvent
TRUNCATE TABLE
"Approval", "SyncRoute", "Message", "ConsentRecord",
"MemberOptOut", "TowerSession", "TowerUser",
"Group", "Account", "TenantBot"
RESTART IDENTITY CASCADE;
-- DDL handled by Prisma migrate diff
```
Pre-migration: `scripts/backup-before-phase2b.sh` (pg_dump data tables only).
## 3. API surface
### Removed
- `POST /accounts`
- `GET /accounts/:id/qr`
- `TOWER_ADMIN_JIDS` env var
### Bot management (OWNER)
| Method | Path | Purpose |
|---|---|---|
| POST | `/admin/bot/initiate` | Create bot row + return pairingToken |
| GET | `/admin/bot/qr/:token` | Poll QR |
| POST | `/admin/bot/confirm` | Mark paired, create TenantBot for caller's tenant |
| GET | `/admin/bot` | Current bot summary |
| POST | `/admin/bot/reveal` | Returns jid (audit-logged) |
| DELETE | `/admin/bot/:id` | Logout + delete session |
### Group claim (OWNER)
| Method | Path | Purpose |
|---|---|---|
| GET | `/admin/groups/pending` | List PENDING_CLAIM groups |
| POST | `/admin/groups/:id/claim` | First-claim-wins |
| POST | `/admin/groups/:id/release` | Back to PENDING_CLAIM |
| GET | `/admin/groups` | `?status=pending|claimed|expired` |
### Public auth (no JWT)
| Method | Path | Purpose |
|---|---|---|
| POST | `/public/auth/request-otp` | Send OTP via bot DM |
| POST | `/public/auth/verify-otp` | Issue member JWT |
| GET | `/public/onboard/:token` | Group + tenant name only |
### Member portal (member JWT)
| Method | Path | Purpose |
|---|---|---|
| GET | `/my/profile` | Self view |
| GET | `/my/groups` | Groups the user is in |
| GET | `/my/groups/:id` | Group metadata + scopes |
| POST | `/my/opt-out` | Body `{ groupId?, scopes? }` |
| POST | `/my/opt-in` | Re-grant |
| DELETE | `/my/account` | Hard delete self |
## 4. Worker changes
- `main.ts`: read `isBot=true` accounts at startup; resolve `tenantId` from `Group.tenantId` per message.
- `group-sync.ts`: on `group-participants.update` with bot JID → upsert with `claimStatus=PENDING_CLAIM`, `claimExpiresAt=now+7d`, `tenantId=null`. Post generic intro. Emit audit.
- `claim-expiry.processor.ts` (NEW): hourly BullMQ repeatable job, mark expired.
- `otp-sender.ts` (NEW): DM OTP via pool.
- `command-handler.ts` (NEW): `STOP` / `START` / `PORTAL` keyword handler.
- `ingest.ts`: filter non-CLAIMED, opt-out aware, set `senderTowerUserId`.
## 5. Web UI
- `/settings/bot`: pairing UI, status, reveal number toggle, copy, remove.
- `/groups`: tabs `My Tenant | Pending Claim | All Claimed`. Claim/release buttons.
- `/onboard` (public): OTP flow, consent scope picker.
- `/my/*` (member): dashboard, group detail with opt-out, settings, delete account.
## 6. Risks / explicit deviations from PDF
- Baileys vs WhatsApp Business Platform (PDF line 62-68): user chose Baileys.
- Opt-in vs opt-out default: tenant-scoped `consentMode` toggle, default `OPT_OUT`. ADR.
- ABAC PDP not implemented (RBAC + scopes).
- OpenLineage / OpenTelemetry not in Phase 2B.
## 7. Execution order
1. Backup script
2. Schema + migration + truncate
3. Bot service + /admin/bot/* API
4. Worker: read isBot=true accounts
5. Pair the new SIM (manual)
6. group-sync PENDING_CLAIM + intro
7. /admin/groups/pending + claim/release API + UI tabs
8. Claim-expiry worker
9. Member models + TowerUser/ConsentRecord/OptOut
10. OTP + /public/auth/* + /onboard
11. /my/* portal + opt-out
12. Command handler
13. Ingest filter
14. Reveal-number audit
15. Test + lint + typecheck