6 JWT Authentication API and Route Guards Feature IN_PROGRESS 0/2 cp
Implement the login and registration API endpoints with JWT issuance, NestJS guards for RBAC, and Next.js middleware to protect authenticated routes.
POST /api/v1/auth/login — validate email+password against User.passwordHash (bcrypt), return signed JWT with userId, organizationId, role claims and 7-day expiry POST /api/v1/auth/register — create Organization + first User (admin role) with bcrypt-hashed password; enforce unique email GET /api/v1/auth/me — return authenticated user profile decoded from JWT bearer token JwtStrategy + JwtAuthGuard using @nestjs/passport + passport-jwt; apply globally with public-route override decorator RolesGuard + @Roles() decorator for endpoint-level RBAC enforcing admin/manager/sales_rep/viewer hierarchy AuthController + AuthService + AuthModule in apps/api/src/modules/auth/ apps/web/src/lib/auth.ts — JWT stored in httpOnly cookie via Next.js /api/auth route, useAuth() hook returning current user apps/web/src/middleware.ts — Next.js middleware redirecting unauthenticated requests to /login, whitelist /login and /proposals/*/view

Started: Mar 1, 06:52:31  

6-1772347952129   0/2 checkpoints Mar 1, 06:52:32 · 6m 12s
6-CP1 NestJS Auth Module — login, register, me, JWT strategy, guards pending
Goal: Implement the complete NestJS authentication backend: AuthModule with AuthService, AuthController, JwtStrategy, global JwtAuthGuard with @Public() override, and RolesGuard with @Roles() decorator
Criteria: POST /api/v1/auth/login validates credentials with bcrypt and returns a signed JWT (userId, organizationId, role, 7-day expiry); POST /api/v1/auth/register creates Organization + admin User with bcrypt-hashed password, returning 409 on duplicate email; GET /api/v1/auth/me returns the authenticated user profile from the JWT bearer token, returning 401 without valid token; JwtAuthGuard is registered as APP_GUARD globally; @Public() decorator bypasses it on login and register routes; RolesGuard + @Roles() decorator enforce ADMIN > MANAGER > SALES_REP > VIEWER hierarchy; pnpm turbo build typecheck --filter=@infurnia-sales/api exits with code 0
Show Dev Prompt
Read ARCHITECTURE.md first for project-wide constraints.

You are implementing checkpoint 6-CP1: NestJS Auth Module for the Infurnia Sales API.

Explore the repo first:
- Examine apps/api/src/ to understand existing module structure, naming conventions, and patterns
- Examine apps/api/prisma/schema.prisma for existing models and datasource config
- Examine apps/api/src/app.module.ts for existing module registrations and imports
- Examine apps/api/package.json for installed dependencies
- Examine packages/shared/src/ for existing shared types and index exports
- Look for a PrismaService or PrismaModule in apps/api/src/ to understand how DB access is provided

IMPLEMENT the following:

1. packages/shared/src/auth.ts — Export:
   - Role enum: ADMIN = 'ADMIN', MANAGER = 'MANAGER', SALES_REP = 'SALES_REP', VIEWER = 'VIEWER'
   - JwtPayload interface: { userId: string; organizationId: string; role: Role; iat?: number; exp?: number }
   - AuthUserDto class: { id: string; email: string; role: Role; organizationId: string }
   Re-export from packages/shared/src/index.ts.

2. apps/api/prisma/schema.prisma — Add/update models:
   - Role enum: ADMIN, MANAGER, SALES_REP, VIEWER
   - Organization model: id String @id @default(uuid()), name String, createdAt DateTime @default(now()), updatedAt DateTime @updatedAt, users User[]
   - User model: id String @id @default(uuid()), email String @unique, passwordHash String, role Role @default(VIEWER), organizationId String, organization Organization @relation(fields: [organizationId], references: [id]), createdAt DateTime @default(now()), updatedAt DateTime @updatedAt
   Preserve existing datasource and generator blocks.

3. apps/api/src/modules/auth/dto/login.dto.ts — LoginDto { @IsEmail() email: string; @IsString() @MinLength(8) password: string } with @ApiProperty() on each field.

4. apps/api/src/modules/auth/dto/register.dto.ts — RegisterDto { @IsString() @MinLength(2) organizationName: string; @IsEmail() email: string; @IsString() @MinLength(8) password: string } with @ApiProperty() on each field.

5. apps/api/src/modules/auth/dto/auth-response.dto.ts — AuthResponseDto { @ApiProperty() accessToken: string; @ApiProperty() user: AuthUserDto }.

6. apps/api/src/modules/auth/decorators/public.decorator.ts:
   export const IS_PUBLIC_KEY = 'isPublic';
   export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

7. apps/api/src/modules/auth/decorators/roles.decorator.ts:
   export const ROLES_KEY = 'roles';
   export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

8. apps/api/src/modules/auth/strategies/jwt.strategy.ts:
   - PassportStrategy(Strategy, 'jwt') from passport-jwt
   - Extract bearer from Authorization header: ExtractJwt.fromAuthHeaderAsBearerToken()
   - secretOrKey: process.env.JWT_SECRET || 'dev-secret'
   - validate(payload: JwtPayload): returns payload as-is

9. apps/api/src/modules/auth/guards/jwt-auth.guard.ts:
   - @Injectable() class JwtAuthGuard extends AuthGuard('jwt')
   - Inject Reflector in constructor
   - canActivate: check IS_PUBLIC_KEY via reflector.getAllAndOverride; if true return true; else return super.canActivate(context)

10. apps/api/src/modules/auth/guards/roles.guard.ts:
    - @Injectable() implements CanActivate
    - Inject Reflector
    - canActivate: get roles from ROLES_KEY metadata; if none required return true
    - Get req.user as JwtPayload; define roleHierarchy = [Role.VIEWER, Role.SALES_REP, Role.MANAGER, Role.ADMIN]
    - Return true if user role index >= minimum required role index in hierarchy

11. apps/api/src/modules/auth/auth.service.ts:
    - @Injectable(), inject PrismaService and JwtService
    - async login(dto: LoginDto): Promise<AuthResponseDto>
      Find user by email; if not found throw UnauthorizedException('Invalid credentials')
      await bcrypt.compare(dto.password, user.passwordHash); if false throw UnauthorizedException('Invalid credentials')
      Sign JWT: jwtService.sign({ userId: user.id, organizationId: user.organizationId, role: user.role })
      Return { accessToken, user: { id, email, role, organizationId } }
    - async register(dto: RegisterDto): Promise<AuthResponseDto>
      Check if user exists by email; if found throw ConflictException('Email already in use')
      passwordHash = await bcrypt.hash(dto.password, 10)
      Use prisma.$transaction: create Organization { name: dto.organizationName }, then create User { email, passwordHash, role: Role.ADMIN, organizationId: org.id }
      Sign JWT, return AuthResponseDto
    - async getMe(userId: string): Promise<AuthUserDto>
      Find user by id; if not found throw UnauthorizedException()
      Return { id, email, role, organizationId }

12. apps/api/src/modules/auth/auth.controller.ts:
    - @ApiTags('auth') @Controller('auth')
    - @Post('login') @Public() @HttpCode(200) @ApiOperation({ summary: 'Login' }) @ApiResponse({ status: 200, type: AuthResponseDto }) @ApiResponse({ status: 401, description: 'Invalid credentials' })
      login(@Body() dto: LoginDto): return this.authService.login(dto)
    - @Post('register') @Public() @ApiOperation({ summary: 'Register organization and admin user' }) @ApiResponse({ status: 201, type: AuthResponseDto }) @ApiResponse({ status: 409, description: 'Email already in use' })
      register(@Body() dto: RegisterDto): return this.authService.register(dto)
    - @Get('me') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get current user profile' }) @ApiResponse({ status: 200, type: AuthUserDto }) @ApiResponse({ status: 401 })
      getMe(@Request() req): return this.authService.getMe(req.user.userId)

13. apps/api/src/modules/auth/auth.module.ts:
    - imports: PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_SECRET || 'dev-secret', signOptions: { expiresIn: '7d' } })
    - controllers: [AuthController]
    - providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard]
    - exports: [JwtAuthGuard, RolesGuard, JwtStrategy]

14. apps/api/src/app.module.ts:
    - Import AuthModule
    - Add to providers: { provide: APP_GUARD, useClass: JwtAuthGuard } (import APP_GUARD from @nestjs/core)

DEPENDENCIES: Check apps/api/package.json; add to dependencies if missing: @nestjs/passport, @nestjs/jwt, passport, passport-jwt, bcrypt. Add to devDependencies if missing: @types/passport-jwt, @types/bcrypt.

CONSTRAINTS:
- allowedPaths: apps/api/src/modules/auth/**, apps/api/src/app.module.ts, apps/api/src/main.ts, apps/api/prisma/schema.prisma, apps/api/package.json, packages/shared/src/**
- forbiddenPaths: orchestrator/**, *.lock, .env*
- maxDiffLines: 750

OUTPUT: Produce a unified diff patch of all changes. Then output a DevResult JSON object:
{ "checkpointId": "6-CP1", "filesChanged": [...], "commandsRun": [...], "patch": "<full unified diff>", "rationale": "<brief explanation>" }
Show Test Prompt
TEST_CWD: /home/nikhil/orchestrator/runs/6-1772347952129/worktree

Verify checkpoint 6-CP1: NestJS Auth Module.

Confirm the following files exist and are non-empty before running tests:
- apps/api/src/modules/auth/auth.module.ts
- apps/api/src/modules/auth/auth.service.ts
- apps/api/src/modules/auth/auth.controller.ts
- apps/api/src/modules/auth/strategies/jwt.strategy.ts
- apps/api/src/modules/auth/guards/jwt-auth.guard.ts
- apps/api/src/modules/auth/guards/roles.guard.ts
- apps/api/src/modules/auth/decorators/public.decorator.ts
- apps/api/src/modules/auth/decorators/roles.decorator.ts
- packages/shared/src/auth.ts

TEST_COMMANDS:
- pnpm turbo build typecheck test --filter=!@infurnia-sales/mobile

Success criteria:
- Build and typecheck pass for all packages (especially @infurnia-sales/api and @infurnia-sales/shared)
- AuthService unit tests cover login (valid credentials, invalid credentials), register (new user, duplicate email), and getMe
- JwtAuthGuard and RolesGuard unit tests verify public route bypass and role hierarchy enforcement
- No TypeScript errors across any package

Output JSON: { "passed": boolean, "commandsRun": string[], "evidence": string }
6-CP2 Next.js Web Auth — httpOnly cookie, useAuth hook, middleware pending
Goal: Implement Next.js web-side auth: API routes that manage JWT in an httpOnly cookie, useAuth() hook for current user state, and middleware.ts that redirects unauthenticated requests to /login
Criteria: apps/web/src/lib/auth.ts exports token utilities (getAuthToken, setAuthToken, clearAuthToken, decodeToken) operating on the httpOnly 'auth-token' cookie; Next.js route handlers under apps/web/src/app/api/auth/ handle login, register, logout, and me by proxying to the API and managing the cookie; useAuth() hook in apps/web/src/hooks/useAuth.ts returns { user, isLoading, login, logout, register } using React Query; apps/web/src/middleware.ts redirects unauthenticated users to /login, whitelisting /login and paths matching /proposals/*/view; pnpm turbo build typecheck --filter=@infurnia-sales/web exits with code 0
Show Dev Prompt
Read ARCHITECTURE.md first for project-wide constraints.

You are implementing checkpoint 6-CP2: Next.js Web Auth for the Infurnia Sales web app.

Explore the repo first:
- Examine apps/web/src/ structure (app/, lib/, hooks/, existing middleware.ts if any)
- Examine apps/web/package.json for installed dependencies (Next.js version, React Query, jose/jwt-decode)
- Examine packages/shared/src/auth.ts for JwtPayload, Role, AuthUserDto types (created in CP1)
- Look at apps/web/src/app/ to understand the App Router layout and existing route structure
- Check if @tanstack/react-query or react-query is already installed

IMPLEMENT the following:

1. apps/web/src/lib/auth.ts:
   Import { JwtPayload } from '@infurnia-sales/shared'
   Import { NextResponse } from 'next/server'
   Import { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies' (or use cookies() return type)

   export const AUTH_COOKIE = 'auth-token'

   export function getAuthToken(cookieStore: { get(name: string): { value: string } | undefined }): string | undefined {
     return cookieStore.get(AUTH_COOKIE)?.value
   }

   export function setAuthToken(token: string, response: NextResponse): void {
     response.cookies.set(AUTH_COOKIE, token, {
       httpOnly: true,
       secure: process.env.NODE_ENV === 'production',
       sameSite: 'strict',
       path: '/',
       maxAge: 60 * 60 * 24 * 7
     })
   }

   export function clearAuthToken(response: NextResponse): void {
     response.cookies.delete(AUTH_COOKIE)
   }

   export function decodeToken(token: string): JwtPayload | null {
     try {
       const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
       const json = Buffer.from(base64, 'base64').toString('utf-8')
       return JSON.parse(json) as JwtPayload
     } catch {
       return null
     }
   }

2. apps/web/src/app/api/auth/login/route.ts:
   import { NextResponse } from 'next/server'
   import { setAuthToken } from '@/lib/auth'

   export async function POST(request: Request) {
     const body = await request.json()
     const apiRes = await fetch(process.env.NEXT_PUBLIC_API_URL + '/api/v1/auth/login', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify(body)
     })
     const data = await apiRes.json()
     if (!apiRes.ok) return NextResponse.json(data, { status: apiRes.status })
     const response = NextResponse.json({ user: data.user }, { status: 200 })
     setAuthToken(data.accessToken, response)
     return response
   }

3. apps/web/src/app/api/auth/register/route.ts:
   Same pattern as login but calls /api/v1/auth/register and returns status 201 on success.

4. apps/web/src/app/api/auth/logout/route.ts:
   import { NextResponse } from 'next/server'
   import { clearAuthToken } from '@/lib/auth'

   export async function POST() {
     const response = NextResponse.json({ ok: true })
     clearAuthToken(response)
     return response
   }

5. apps/web/src/app/api/auth/me/route.ts:
   import { NextResponse } from 'next/server'
   import { cookies } from 'next/headers'
   import { getAuthToken } from '@/lib/auth'

   export async function GET() {
     const cookieStore = await cookies()
     const token = getAuthToken(cookieStore)
     if (!token) return NextResponse.json({ user: null }, { status: 401 })
     const apiRes = await fetch(process.env.NEXT_PUBLIC_API_URL + '/api/v1/auth/me', {
       headers: { Authorization: 'Bearer ' + token }
     })
     const data = await apiRes.json()
     return NextResponse.json(data, { status: apiRes.status })
   }

6. apps/web/src/hooks/useAuth.ts:
   'use client'
   import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
   import { useRouter } from 'next/navigation'
   import type { AuthUserDto } from '@infurnia-sales/shared'

   export function useAuth() {
     const queryClient = useQueryClient()
     const router = useRouter()

     const { data, isLoading } = useQuery({
       queryKey: ['auth', 'me'],
       queryFn: async () => {
         const res = await fetch('/api/auth/me')
         if (!res.ok) return null
         const json = await res.json()
         return json.user as AuthUserDto | null
       },
       retry: false
     })

     const login = async (email: string, password: string) => {
       const res = await fetch('/api/auth/login', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ email, password })
       })
       if (!res.ok) throw new Error((await res.json()).message || 'Login failed')
       await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
     }

     const logout = async () => {
       await fetch('/api/auth/logout', { method: 'POST' })
       await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
       router.push('/login')
     }

     const register = async (organizationName: string, email: string, password: string) => {
       const res = await fetch('/api/auth/register', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ organizationName, email, password })
       })
       if (!res.ok) throw new Error((await res.json()).message || 'Registration failed')
       await queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
     }

     return { user: data ?? null, isLoading, login, logout, register }
   }

7. apps/web/src/middleware.ts:
   import { NextResponse } from 'next/server'
   import type { NextRequest } from 'next/server'
   import { AUTH_COOKIE } from '@/lib/auth'

   export function middleware(request: NextRequest) {
     const token = request.cookies.get(AUTH_COOKIE)?.value
     const { pathname } = request.nextUrl
     const isWhitelisted = pathname === '/login' || /^\/proposals\/[^\/]+\/view/.test(pathname)
     if (!token && !isWhitelisted) {
       return NextResponse.redirect(new URL('/login', request.url))
     }
     return NextResponse.next()
   }

   export const config = {
     matcher: ['/((?!_next/static|_next/image|favicon.ico|public/).*)'],
   }

Note: The middleware imports '@/lib/auth' which uses AUTH_COOKIE constant only — ensure decodeToken and cookie utilities that use Buffer or NextResponse are NOT imported in middleware to avoid Edge Runtime compatibility issues. Consider exporting AUTH_COOKIE as a plain string constant from a separate file (apps/web/src/lib/auth-constants.ts) if needed to avoid bundling Node.js APIs into the Edge runtime.

DEPENDENCIES: Ensure apps/web/package.json includes @tanstack/react-query in dependencies. Add if missing.

CONSTRAINTS:
- allowedPaths: apps/web/src/lib/**, apps/web/src/app/api/auth/**, apps/web/src/hooks/**, apps/web/src/middleware.ts, apps/web/package.json, packages/shared/src/**
- forbiddenPaths: orchestrator/**, *.lock, .env*
- maxDiffLines: 500

OUTPUT: Produce a unified diff patch of all changes. Then output a DevResult JSON object:
{ "checkpointId": "6-CP2", "filesChanged": [...], "commandsRun": [...], "patch": "<full unified diff>", "rationale": "<brief explanation>" }
Show Test Prompt
TEST_CWD: /home/nikhil/orchestrator/runs/6-1772347952129/worktree

Verify checkpoint 6-CP2: Next.js Web Auth.

Confirm the following files exist and are non-empty before running tests:
- apps/web/src/lib/auth.ts
- apps/web/src/app/api/auth/login/route.ts
- apps/web/src/app/api/auth/register/route.ts
- apps/web/src/app/api/auth/logout/route.ts
- apps/web/src/app/api/auth/me/route.ts
- apps/web/src/hooks/useAuth.ts
- apps/web/src/middleware.ts

TEST_COMMANDS:
- pnpm turbo build typecheck test --filter=!@infurnia-sales/mobile

Success criteria:
- Build and typecheck pass for all packages (especially @infurnia-sales/web)
- middleware.ts compiles and exports a valid config.matcher
- useAuth hook compiles without TypeScript errors
- All auth route handlers compile without errors
- No TypeScript errors across any package

Output JSON: { "passed": boolean, "commandsRun": string[], "evidence": string }
Show Events (3)
TimeStageMessageData
Mar 1, 06:52:32 WORKTREE Creating isolated worktree...
Mar 1, 06:52:34 WORKTREE Created at /home/nikhil/orchestrator/runs/6-1772347952129/worktree
Mar 1, 06:52:34 PLAN Generating checkpoint plan via Claude Code...

Raw log file