Qadam Roadmap
проектplans/archived/2026-03-28-integrated/2026-03-16-jwt-token-delivery-refactor.md

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

FileActionResponsibility
packages/shared/src/schemas/auth.tsModifyAdd RefreshBodySchema
packages/shared/src/index.tsModifyExport RefreshBodySchema + type
apps/api/src/modules/auth/token-delivery.service.tsCreateToken delivery logic (cookies vs body)
apps/api/src/modules/auth/token-delivery.service.spec.tsCreateUnit tests for delivery service
apps/api/src/common/guards/jwt-auth.guard.tsModifyBearer header + cookie fallback
apps/api/src/common/guards/jwt-auth.guard.spec.tsCreateGuard unit tests
apps/api/src/modules/auth/auth.controller.tsModifyUse TokenDeliveryService, accept body refresh token
apps/api/src/modules/auth/auth.controller.spec.tsCreateController tests for web/mobile flows
apps/api/src/modules/auth/auth.module.tsModifyRegister 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
)"