JWT Token Delivery Refactor — Implementation Plan
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
JWT Token Delivery Refactor — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refactor JWT auth to support dual delivery (httpOnly cookies for web, response body for mobile) via X-Client-Type header detection.
Architecture: New TokenDeliveryService encapsulates delivery logic. JwtAuthGuard extracts token from Bearer header (priority) or qadam_at cookie (fallback). Controller stays thin — delegates to delivery service.
Tech Stack: NestJS, Fastify, JWT, Zod, Jest
Spec: docs/superpowers/specs/2026-03-16-jwt-token-delivery-refactor-design.md
File Structure
| File | Action | Responsibility |
|---|---|---|
packages/shared/src/schemas/auth.ts | Modify | Add RefreshBodySchema |
packages/shared/src/index.ts | Modify | Export RefreshBodySchema + type |
apps/api/src/modules/auth/token-delivery.service.ts | Create | Token delivery logic (cookies vs body) |
apps/api/src/modules/auth/token-delivery.service.spec.ts | Create | Unit tests for delivery service |
apps/api/src/common/guards/jwt-auth.guard.ts | Modify | Bearer header + cookie fallback |
apps/api/src/common/guards/jwt-auth.guard.spec.ts | Create | Guard unit tests |
apps/api/src/modules/auth/auth.controller.ts | Modify | Use TokenDeliveryService, accept body refresh token |
apps/api/src/modules/auth/auth.controller.spec.ts | Create | Controller tests for web/mobile flows |
apps/api/src/modules/auth/auth.module.ts | Modify | Register TokenDeliveryService |
Chunk 1: Shared Schema + TokenDeliveryService
Task 1: Add RefreshBodySchema to shared package
Files:
-
Modify:
packages/shared/src/schemas/auth.ts:17(append after LoginDTO) -
Modify:
packages/shared/src/index.ts:2-3(add exports) -
Step 1: Add RefreshBodySchema to auth.ts
Append to packages/shared/src/schemas/auth.ts after line 17:
export const RefreshBodySchema = z.object({
refreshToken: z.string().min(1).optional(),
});
export type RefreshBodyDTO = z.infer<typeof RefreshBodySchema>;
- Step 2: Export from shared index.ts
In packages/shared/src/index.ts, change line 2-3 from:
export { RegisterSchema, LoginSchema } from './schemas/auth';
export type { RegisterDTO, LoginDTO } from './schemas/auth';
to:
export { RegisterSchema, LoginSchema, RefreshBodySchema } from './schemas/auth';
export type { RegisterDTO, LoginDTO, RefreshBodyDTO } from './schemas/auth';
- Step 3: Verify types compile
Run: pnpm --filter=@repo/shared check-types
Expected: PASS, no errors
- Step 4: Commit
git add packages/shared/src/schemas/auth.ts packages/shared/src/index.ts
git commit -m "feat(shared): add RefreshBodySchema for mobile refresh flow"
Task 2: Create TokenDeliveryService with tests (TDD)
Files:
-
Create:
apps/api/src/modules/auth/token-delivery.service.ts -
Create:
apps/api/src/modules/auth/token-delivery.service.spec.ts -
Step 1: Write failing tests for TokenDeliveryService
Create apps/api/src/modules/auth/token-delivery.service.spec.ts:
import { TokenDeliveryService } from './token-delivery.service';
import type { FastifyReply, FastifyRequest } from 'fastify';
describe('TokenDeliveryService', () => {
let service: TokenDeliveryService;
beforeEach(() => {
service = new TokenDeliveryService();
});
const mockTokens = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
const createMockReply = () =>
({
setCookie: jest.fn(),
clearCookie: jest.fn(),
}) as unknown as FastifyReply;
describe('extractClientType', () => {
it('returns "web" when X-Client-Type header is absent', () => {
const req = { headers: {} } as FastifyRequest;
expect(service.extractClientType(req)).toBe('web');
});
it('returns "mobile" when X-Client-Type is "mobile"', () => {
const req = { headers: { 'x-client-type': 'mobile' } } as unknown as FastifyRequest;
expect(service.extractClientType(req)).toBe('mobile');
});
it('returns "web" for any non-mobile value', () => {
const req = { headers: { 'x-client-type': 'desktop' } } as unknown as FastifyRequest;
expect(service.extractClientType(req)).toBe('web');
});
});
describe('sendTokens', () => {
describe('web mode', () => {
it('sets qadam_at and qadam_rt cookies', () => {
const res = createMockReply();
service.sendTokens(res, mockTokens, 'web');
expect(res.setCookie).toHaveBeenCalledWith(
'qadam_at',
'mock-access-token',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 15 * 60,
}),
);
expect(res.setCookie).toHaveBeenCalledWith(
'qadam_rt',
'mock-refresh-token',
expect.objectContaining({
httpOnly: true,
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: 7 * 24 * 60 * 60,
}),
);
});
it('returns empty object (no tokens in body)', () => {
const res = createMockReply();
const result = service.sendTokens(res, mockTokens, 'web');
expect(result).toEqual({});
});
});
describe('mobile mode', () => {
it('does NOT set cookies', () => {
const res = createMockReply();
service.sendTokens(res, mockTokens, 'mobile');
expect(res.setCookie).not.toHaveBeenCalled();
});
it('returns tokens in body', () => {
const res = createMockReply();
const result = service.sendTokens(res, mockTokens, 'mobile');
expect(result).toEqual({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
});
});
});
});
describe('clearTokens', () => {
it('clears cookies for web mode', () => {
const res = createMockReply();
service.clearTokens(res, 'web');
expect(res.clearCookie).toHaveBeenCalledWith(
'qadam_at',
expect.objectContaining({ path: '/' }),
);
expect(res.clearCookie).toHaveBeenCalledWith(
'qadam_rt',
expect.objectContaining({ path: '/api/v1/auth' }),
);
});
it('does NOT clear cookies for mobile mode', () => {
const res = createMockReply();
service.clearTokens(res, 'mobile');
expect(res.clearCookie).not.toHaveBeenCalled();
});
});
});
- Step 2: Run tests to verify they fail
Run: cd apps/api && npx jest token-delivery.service.spec.ts --no-coverage
Expected: FAIL — Cannot find module './token-delivery.service'
- Step 3: Implement TokenDeliveryService
Create apps/api/src/modules/auth/token-delivery.service.ts:
import { Injectable } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
export type ClientType = 'web' | 'mobile';
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
type TokenResponseBody = Record<string, never> | { accessToken: string; refreshToken: string };
const ACCESS_MAX_AGE = 15 * 60;
const REFRESH_MAX_AGE = 7 * 24 * 60 * 60;
@Injectable()
export class TokenDeliveryService {
private readonly isProduction = process.env.NODE_ENV === 'production';
extractClientType(req: FastifyRequest): ClientType {
const header = req.headers['x-client-type'];
return header === 'mobile' ? 'mobile' : 'web';
}
sendTokens(res: FastifyReply, tokens: TokenPair, clientType: ClientType): TokenResponseBody {
if (clientType === 'mobile') {
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken };
}
const shared = {
httpOnly: true,
secure: this.isProduction,
sameSite: 'lax' as const,
};
res.setCookie('qadam_at', tokens.accessToken, {
...shared,
path: '/',
maxAge: ACCESS_MAX_AGE,
});
res.setCookie('qadam_rt', tokens.refreshToken, {
...shared,
path: '/api/v1/auth',
maxAge: REFRESH_MAX_AGE,
});
return {} as Record<string, never>;
}
clearTokens(res: FastifyReply, clientType: ClientType): void {
if (clientType === 'mobile') return;
const shared = {
httpOnly: true,
secure: this.isProduction,
sameSite: 'lax' as const,
};
res.clearCookie('qadam_at', { ...shared, path: '/' });
res.clearCookie('qadam_rt', { ...shared, path: '/api/v1/auth' });
}
}
- Step 4: Run tests to verify they pass
Run: cd apps/api && npx jest token-delivery.service.spec.ts --no-coverage
Expected: PASS, all 7 tests green
- Step 5: Commit
git add apps/api/src/modules/auth/token-delivery.service.ts apps/api/src/modules/auth/token-delivery.service.spec.ts
git commit -m "feat(auth): add TokenDeliveryService with web/mobile dual delivery"
Chunk 2: JwtAuthGuard + AuthController + Module Wiring
Task 3: Update JwtAuthGuard to support Bearer header (TDD)
Files:
-
Modify:
apps/api/src/common/guards/jwt-auth.guard.ts -
Create:
apps/api/src/common/guards/jwt-auth.guard.spec.ts -
Step 1: Write failing tests for updated guard
Create apps/api/src/common/guards/jwt-auth.guard.spec.ts:
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { JwtAuthGuard } from './jwt-auth.guard';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: jest.Mocked<Reflector>;
let jwtService: jest.Mocked<JwtService>;
const mockPayload = { sub: 'uuid-1', email: 'test@test.com', type: 'BUYER', tokenType: 'access' };
const createContext = (overrides: {
cookies?: Record<string, string>;
authorization?: string;
} = {}): ExecutionContext => {
const request = {
cookies: overrides.cookies ?? {},
headers: {
authorization: overrides.authorization,
},
user: undefined,
};
return {
switchToHttp: () => ({ getRequest: () => request }),
getHandler: () => ({}),
getClass: () => ({}),
} as unknown as ExecutionContext;
};
beforeEach(() => {
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) } as unknown as jest.Mocked<Reflector>;
jwtService = { verify: jest.fn().mockReturnValue(mockPayload) } as unknown as jest.Mocked<JwtService>;
guard = new JwtAuthGuard(reflector, jwtService);
});
it('allows public routes without token extraction', () => {
reflector.getAllAndOverride.mockReturnValue(true);
const ctx = createContext();
expect(guard.canActivate(ctx)).toBe(true);
});
it('extracts token from Authorization Bearer header', () => {
const ctx = createContext({ authorization: 'Bearer valid-token' });
expect(guard.canActivate(ctx)).toBe(true);
expect(jwtService.verify).toHaveBeenCalledWith('valid-token');
});
it('falls back to qadam_at cookie when no Bearer header', () => {
const ctx = createContext({ cookies: { qadam_at: 'cookie-token' } });
expect(guard.canActivate(ctx)).toBe(true);
expect(jwtService.verify).toHaveBeenCalledWith('cookie-token');
});
it('prefers Bearer header over cookie when both present', () => {
const ctx = createContext({
authorization: 'Bearer bearer-token',
cookies: { qadam_at: 'cookie-token' },
});
expect(guard.canActivate(ctx)).toBe(true);
expect(jwtService.verify).toHaveBeenCalledWith('bearer-token');
});
it('throws UnauthorizedException when no token found', () => {
const ctx = createContext();
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
});
it('throws UnauthorizedException for non-access token type', () => {
jwtService.verify.mockReturnValue({ ...mockPayload, tokenType: 'refresh' });
const ctx = createContext({ authorization: 'Bearer some-token' });
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
});
it('throws UnauthorizedException for expired/invalid token', () => {
jwtService.verify.mockImplementation(() => { throw new Error('jwt expired'); });
const ctx = createContext({ authorization: 'Bearer expired-token' });
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
});
it('attaches user to request on success', () => {
const ctx = createContext({ authorization: 'Bearer valid-token' });
guard.canActivate(ctx);
const request = ctx.switchToHttp().getRequest();
expect(request.user).toEqual({ sub: 'uuid-1', email: 'test@test.com', type: 'BUYER' });
});
});
- Step 2: Run tests — some should fail (old guard reads only cookie)
Run: cd apps/api && npx jest jwt-auth.guard.spec.ts --no-coverage
Expected: Tests that use Bearer header should FAIL (guard currently only checks cookies)
- Step 3: Update JwtAuthGuard implementation
Replace apps/api/src/common/guards/jwt-auth.guard.ts entirely:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('Authentication required');
}
try {
const payload = this.jwtService.verify(token);
if (payload.tokenType !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
request.user = { sub: payload.sub, email: payload.email, type: payload.type };
return true;
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(request: { headers: Record<string, string | undefined>; cookies?: Record<string, string> }): string | null {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return request.cookies?.qadam_at ?? null;
}
}
- Step 4: Run tests to verify all pass
Run: cd apps/api && npx jest jwt-auth.guard.spec.ts --no-coverage
Expected: PASS, all 8 tests green
- Step 5: Commit
git add apps/api/src/common/guards/jwt-auth.guard.ts apps/api/src/common/guards/jwt-auth.guard.spec.ts
git commit -m "feat(auth): update JwtAuthGuard to support Bearer header with cookie fallback"
Task 4: Refactor AuthController to use TokenDeliveryService
Files:
-
Modify:
apps/api/src/modules/auth/auth.controller.ts -
Modify:
apps/api/src/modules/auth/auth.module.ts -
Step 1: Register TokenDeliveryService in auth module
Replace apps/api/src/modules/auth/auth.module.ts:
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthRepository } from './repositories/auth.repository';
import { TokenDeliveryService } from './token-delivery.service';
@Module({
controllers: [AuthController],
providers: [AuthService, AuthRepository, TokenDeliveryService],
exports: [AuthService],
})
export class AuthModule {}
- Step 2: Refactor AuthController
Replace apps/api/src/modules/auth/auth.controller.ts:
import {
Controller,
Post,
Get,
Body,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { RegisterSchema, LoginSchema, RefreshBodySchema } from '@repo/shared';
import type { RegisterDTO, LoginDTO, RefreshBodyDTO } from '@repo/shared';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { AuthService } from './auth.service';
import { TokenDeliveryService } from './token-delivery.service';
import { TrackEvent } from '../tracking/track-event.decorator';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly tokenDelivery: TokenDeliveryService,
) {}
@Public()
@Post('register')
@TrackEvent('register')
async register(
@Body(new ZodValidationPipe(RegisterSchema)) dto: RegisterDTO,
@Req() req: FastifyRequest,
@Res({ passthrough: true }) res: FastifyReply,
) {
const clientType = this.tokenDelivery.extractClientType(req);
const { account, accessToken, refreshToken } = await this.authService.register(dto);
const tokenBody = this.tokenDelivery.sendTokens(res, { accessToken, refreshToken }, clientType);
return { user: account, ...tokenBody };
}
@Public()
@Post('login')
@TrackEvent('login')
async login(
@Body(new ZodValidationPipe(LoginSchema)) dto: LoginDTO,
@Req() req: FastifyRequest,
@Res({ passthrough: true }) res: FastifyReply,
) {
const clientType = this.tokenDelivery.extractClientType(req);
const { account, accessToken, refreshToken } = await this.authService.login(dto);
const tokenBody = this.tokenDelivery.sendTokens(res, { accessToken, refreshToken }, clientType);
return { user: account, ...tokenBody };
}
@Get('me')
async me(@CurrentUser('sub') accountId: string) {
return { user: await this.authService.getMe(accountId) };
}
@Public()
@Post('refresh')
async refresh(
@Req() req: FastifyRequest,
@Body(new ZodValidationPipe(RefreshBodySchema)) body: RefreshBodyDTO,
@Res({ passthrough: true }) res: FastifyReply,
) {
const clientType = this.tokenDelivery.extractClientType(req);
const refreshToken = req.cookies?.qadam_rt ?? body?.refreshToken;
if (!refreshToken) {
throw new UnauthorizedException('Refresh token required');
}
const payload = this.authService.verifyRefreshToken(refreshToken);
const account = await this.authService.getMe(payload.sub);
const newAccessToken = this.authService.generateAccessToken(account);
const newRefreshToken = this.authService.generateRefreshToken(account.id);
const tokenBody = this.tokenDelivery.sendTokens(
res,
{ accessToken: newAccessToken, refreshToken: newRefreshToken },
clientType,
);
return { user: account, ...tokenBody };
}
@Post('logout')
async logout(
@Req() req: FastifyRequest,
@Res({ passthrough: true }) res: FastifyReply,
) {
const clientType = this.tokenDelivery.extractClientType(req);
this.tokenDelivery.clearTokens(res, clientType);
return { success: true };
}
}
- Step 3: Run type check
Run: pnpm type-check
Expected: PASS, no type errors
- Step 4: Run existing auth service tests to make sure they still pass
Run: cd apps/api && npx jest auth.service.spec.ts --no-coverage
Expected: PASS (auth service is unchanged)
- Step 5: Run all tests to verify nothing is broken
Run: pnpm test
Expected: PASS
- Step 6: Commit
git add apps/api/src/modules/auth/auth.controller.ts apps/api/src/modules/auth/auth.module.ts
git commit -m "feat(auth): refactor controller to use TokenDeliveryService for dual delivery"
Task 5: Add AuthController tests for web/mobile flows
Files:
-
Create:
apps/api/src/modules/auth/auth.controller.spec.ts -
Step 1: Write controller tests
Create apps/api/src/modules/auth/auth.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { TokenDeliveryService } from './token-delivery.service';
describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<AuthService>;
let tokenDelivery: TokenDeliveryService;
const mockAccount = {
id: 'uuid-1',
email: 'test@example.com',
phone: '+998901234567',
type: 'BUYER' as const,
status: 'ACTIVE' as const,
createdAt: new Date('2026-01-01T00:00:00Z'),
};
const mockTokens = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
const createMockReq = (clientType?: string, cookies?: Record<string, string>): FastifyRequest =>
({
headers: clientType ? { 'x-client-type': clientType } : {},
cookies: cookies ?? {},
}) as unknown as FastifyRequest;
const createMockRes = (): FastifyReply =>
({
setCookie: jest.fn(),
clearCookie: jest.fn(),
}) as unknown as FastifyReply;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [AuthController],
providers: [
TokenDeliveryService,
{
provide: AuthService,
useValue: {
register: jest.fn().mockResolvedValue({ account: mockAccount, ...mockTokens }),
login: jest.fn().mockResolvedValue({ account: mockAccount, ...mockTokens }),
getMe: jest.fn().mockResolvedValue(mockAccount),
verifyRefreshToken: jest.fn().mockReturnValue({ sub: 'uuid-1', tokenType: 'refresh' }),
generateAccessToken: jest.fn().mockReturnValue('new-access-token'),
generateRefreshToken: jest.fn().mockReturnValue('new-refresh-token'),
},
},
],
}).compile();
controller = module.get(AuthController);
authService = module.get(AuthService);
tokenDelivery = module.get(TokenDeliveryService);
});
describe('login', () => {
const dto = { login: 'test@example.com', password: 'password123' };
it('returns only user for web client (tokens in cookies)', async () => {
const req = createMockReq();
const res = createMockRes();
const result = await controller.login(dto, req, res);
expect(result).toEqual({ user: mockAccount });
expect(res.setCookie).toHaveBeenCalledWith('qadam_at', 'mock-access-token', expect.any(Object));
expect(res.setCookie).toHaveBeenCalledWith('qadam_rt', 'mock-refresh-token', expect.any(Object));
});
it('returns user + tokens for mobile client (no cookies)', async () => {
const req = createMockReq('mobile');
const res = createMockRes();
const result = await controller.login(dto, req, res);
expect(result).toEqual({
user: mockAccount,
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
});
expect(res.setCookie).not.toHaveBeenCalled();
});
});
describe('register', () => {
const dto = { email: 'test@example.com', phone: '+998901234567', password: 'password123', type: 'BUYER' as const };
it('returns only user for web client', async () => {
const req = createMockReq();
const res = createMockRes();
const result = await controller.register(dto, req, res);
expect(result).toEqual({ user: mockAccount });
expect(res.setCookie).toHaveBeenCalledTimes(2);
});
it('returns user + tokens for mobile client', async () => {
const req = createMockReq('mobile');
const res = createMockRes();
const result = await controller.register(dto, req, res);
expect(result).toEqual({
user: mockAccount,
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
});
expect(res.setCookie).not.toHaveBeenCalled();
});
});
describe('refresh', () => {
it('reads refresh token from qadam_rt cookie (web)', async () => {
const req = createMockReq(undefined, { qadam_rt: 'cookie-refresh-token' });
const res = createMockRes();
const result = await controller.refresh(req, {}, res);
expect(authService.verifyRefreshToken).toHaveBeenCalledWith('cookie-refresh-token');
expect(result).toEqual({ user: mockAccount });
expect(res.setCookie).toHaveBeenCalledTimes(2);
});
it('reads refresh token from body (mobile)', async () => {
const req = createMockReq('mobile');
const res = createMockRes();
const result = await controller.refresh(req, { refreshToken: 'body-refresh-token' }, res);
expect(authService.verifyRefreshToken).toHaveBeenCalledWith('body-refresh-token');
expect(result).toEqual(expect.objectContaining({
user: mockAccount,
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
}));
expect(res.setCookie).not.toHaveBeenCalled();
});
it('throws when no refresh token in cookie or body', async () => {
const req = createMockReq();
const res = createMockRes();
await expect(controller.refresh(req, {}, res)).rejects.toThrow(UnauthorizedException);
});
});
describe('logout', () => {
it('clears cookies for web client', async () => {
const req = createMockReq();
const res = createMockRes();
const result = await controller.logout(req, res);
expect(result).toEqual({ success: true });
expect(res.clearCookie).toHaveBeenCalledWith('qadam_at', expect.any(Object));
expect(res.clearCookie).toHaveBeenCalledWith('qadam_rt', expect.any(Object));
});
it('does not clear cookies for mobile client', async () => {
const req = createMockReq('mobile');
const res = createMockRes();
const result = await controller.logout(req, res);
expect(result).toEqual({ success: true });
expect(res.clearCookie).not.toHaveBeenCalled();
});
});
});
- Step 2: Run controller tests
Run: cd apps/api && npx jest auth.controller.spec.ts --no-coverage
Expected: PASS, all 9 tests green
- Step 3: Commit
git add apps/api/src/modules/auth/auth.controller.spec.ts
git commit -m "test(auth): add controller tests for web/mobile dual delivery flows"
Task 6: Final verification and cleanup
- Step 1: Run full CI check sequence
pnpm type-check && pnpm lint && pnpm test
Expected: All three pass with no errors.
- Step 2: Fix any lint issues
If lint reports issues (e.g., import ordering), fix them.
- Step 3: Final commit if any fixes were needed
git add -A
git commit -m "fix(auth): address lint issues from token delivery refactor"
(Skip if no fixes needed.)
- Step 4: Create PR
git push -u origin HEAD
gh pr create \
--title "feat(auth): dual JWT delivery for web/mobile via X-Client-Type" \
--body "$(cat <<'EOF'
## Summary
- Add `TokenDeliveryService` for web (httpOnly cookies) / mobile (response body) dual token delivery
- Rename cookies: `qadam_access` → `qadam_at`, `qadam_refresh` → `qadam_rt`
- Update `JwtAuthGuard` to check `Authorization: Bearer` header first, `qadam_at` cookie as fallback
- Add `RefreshBodySchema` for mobile refresh flow (token in request body)
- Detect client type via `X-Client-Type` request header
## Spec
`docs/superpowers/specs/2026-03-16-jwt-token-delivery-refactor-design.md`
## Test plan
- [ ] TokenDeliveryService: web sets cookies + returns {}, mobile returns tokens in body
- [ ] JwtAuthGuard: Bearer header priority, cookie fallback, both missing = 401
- [ ] Login/register: web gets cookies only, mobile gets tokens in body
- [ ] Refresh: accepts cookie (web) or body (mobile)
- [ ] Logout: clears cookies (web), no-op (mobile)
- [ ] `pnpm type-check && pnpm lint && pnpm test` all green
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"