▶
6
JWT Authentication API and Route Guards
Feature
IN_PROGRESS
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
6-1772347952129
▶
6-CP1
NestJS Auth Module — login, register, me, JWT strategy, guards
pending
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
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)
| Time | Stage | Message | Data |
|---|---|---|---|
| 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... |