Skip to content

Backend Init Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Scaffold the NestJS 11 backend with a complete auth module (register/login/refresh/logout), Prisma 5 schema, device registration, and E2E tests — the foundation for all Avelia backend features.

Architecture: Manual NestJS setup (avoids CLI conflicts with existing directory), TypeScript strict mode, Prisma 5 for PostgreSQL, JWT access+refresh auth with Argon2 password hashing, argon2Salt stored per-user so the Flutter client can derive the MEK locally. Refresh tokens are JWTs signed with a separate secret, hashed and stored in DB for rotation/revocation.

Tech Stack: Node.js 25, NestJS 11, TypeScript 5 (strict), pnpm, PostgreSQL 16, Prisma 5, @node-rs/argon2, @nestjs/jwt, passport-jwt, Jest, Supertest


Pre-flight

bash
# Confirm tools
node --version   # must be v20+
pnpm --version   # must be v8+
psql --version   # PostgreSQL must be reachable

# Create local databases
createdb avelia_dev
createdb avelia_test

# Working directory for all tasks
cd /Volumes/exSSD/dev/baby-parents-app/avelia-backend

Task 1: Git init + project config files

Files:

  • Create: /Volumes/exSSD/dev/baby-parents-app/.gitignore
  • Create: package.json
  • Create: tsconfig.json
  • Create: tsconfig.build.json
  • Create: nest-cli.json
  • Create: .prettierrc
  • Create: .eslintrc.js

Step 1: Init git at project root

bash
cd /Volumes/exSSD/dev/baby-parents-app
git init

Expected: Initialized empty Git repository in ...

Step 2: Create root .gitignore

Create /Volumes/exSSD/dev/baby-parents-app/.gitignore:

gitignore
# Node
node_modules/
dist/
.pnpm-store/

# Environment
.env
.env.test
.env.local

# Flutter
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/
*.g.dart
*.freezed.dart
*.gr.dart
*.retrofit.dart

# Dart
pubspec.lock

# IDE
.vscode/settings.json
.idea/
*.iml

# OS
.DS_Store
Thumbs.db

# Prisma
prisma/migrations/dev.db

Step 3: Create avelia-backend/package.json

json
{
  "name": "avelia-backend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:e2e": "jest --config ./test/jest-e2e.json --runInBand",
    "prisma:migrate": "prisma migrate dev",
    "prisma:migrate:deploy": "prisma migrate deploy",
    "prisma:studio": "prisma studio",
    "prisma:generate": "prisma generate"
  },
  "dependencies": {},
  "devDependencies": {}
}

Step 4: Create tsconfig.json

json
{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2022",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "strictBindCallApply": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true
  }
}

Step 5: Create tsconfig.build.json

json
{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

Step 6: Create nest-cli.json

json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true
  }
}

Step 7: Create .prettierrc

json
{
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}

Step 8: Create .eslintrc.js

js
module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: 'tsconfig.json',
    tsconfigRootDir: __dirname,
    sourceType: 'module',
  },
  plugins: ['@typescript-eslint/eslint-plugin'],
  extends: [
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
  ],
  root: true,
  env: { node: true, jest: true },
  ignorePatterns: ['.eslintrc.js'],
  rules: {
    '@typescript-eslint/interface-name-prefix': 'off',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
  },
};

Step 9: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add .gitignore avelia-backend/package.json avelia-backend/tsconfig.json avelia-backend/tsconfig.build.json avelia-backend/nest-cli.json avelia-backend/.prettierrc avelia-backend/.eslintrc.js
git commit -m "chore(backend): add project config files"

Task 2: Install dependencies

Step 1: Install runtime dependencies

bash
cd /Volumes/exSSD/dev/baby-parents-app/avelia-backend

pnpm add \
  @nestjs/common@^11 \
  @nestjs/core@^11 \
  @nestjs/platform-express@^11 \
  @nestjs/config@^3 \
  @nestjs/jwt@^10 \
  @nestjs/passport@^10 \
  @nestjs/mapped-types@^2 \
  passport \
  passport-jwt \
  @node-rs/argon2 \
  @prisma/client \
  class-validator \
  class-transformer \
  reflect-metadata \
  rxjs

Step 2: Install dev dependencies

bash
pnpm add -D \
  @nestjs/cli@^11 \
  @nestjs/schematics@^11 \
  @nestjs/testing@^11 \
  @types/express \
  @types/jest \
  @types/node \
  @types/passport-jwt \
  @types/supertest \
  @typescript-eslint/eslint-plugin \
  @typescript-eslint/parser \
  eslint \
  eslint-config-prettier \
  eslint-plugin-prettier \
  jest \
  prettier \
  prisma \
  source-map-support \
  supertest \
  ts-jest \
  ts-node \
  tsconfig-paths \
  typescript \
  jest-mock-extended

Step 3: Add Jest config to package.json

Add this "jest" block inside package.json (at the root level of the JSON, alongside "scripts"):

json
"jest": {
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": "src",
  "testRegex": ".*\\.spec\\.ts$",
  "transform": { "^.+\\.(t|j)s$": "ts-jest" },
  "collectCoverageFrom": ["**/*.(t|j)s"],
  "coverageDirectory": "../coverage",
  "testEnvironment": "node"
}

Step 4: Create test/jest-e2e.json

bash
mkdir -p test
json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": { "^.+\\.(t|j)s$": "ts-jest" },
  "globalSetup": "./test/global-setup.ts"
}

Step 5: Create test/global-setup.ts (runs prisma migrate reset on test DB before suite)

typescript
import { execSync } from 'child_process';

export default async function () {
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ??
    'postgresql://localhost:5432/avelia_test';
  execSync('npx prisma migrate deploy', {
    env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL },
    stdio: 'inherit',
  });
}

Step 6: Create .env from .env.example

bash
cp .env.example .env
# Then edit .env:
# DATABASE_URL="postgresql://localhost:5432/avelia_dev"
# JWT_SECRET="dev-secret-change-in-prod"
# JWT_REFRESH_SECRET="dev-refresh-secret-change-in-prod"
# JWT_EXPIRES_IN="15m"
# JWT_REFRESH_EXPIRES_IN="7d"
# PORT=3000
# NODE_ENV=development

Step 7: Create .env.test

bash
cat > .env.test << 'EOF'
DATABASE_URL="postgresql://localhost:5432/avelia_test"
TEST_DATABASE_URL="postgresql://localhost:5432/avelia_test"
JWT_SECRET="test-secret"
JWT_REFRESH_SECRET="test-refresh-secret"
JWT_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d"
PORT=3001
NODE_ENV=test
EOF

Step 8: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/
git commit -m "chore(backend): install dependencies and jest config"

Task 3: Prisma schema + migration

Files:

  • Create: prisma/schema.prisma
  • Run: prisma migrate dev

Step 1: Init Prisma

bash
cd /Volumes/exSSD/dev/baby-parents-app/avelia-backend
pnpm prisma init --datasource-provider postgresql

Expected: creates prisma/schema.prisma and updates .env with DATABASE_URL placeholder (already set).

Step 2: Replace prisma/schema.prisma with full schema

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String        @id @default(cuid())
  email         String        @unique
  passwordHash  String
  argon2Salt    String
  createdAt     DateTime      @default(now())
  updatedAt     DateTime      @updatedAt

  devices       Device[]
  refreshTokens RefreshToken[]
  partnerships  Partnership[] @relation("requester")
  partnerOf     Partnership[] @relation("target")
}

model Device {
  id            String        @id @default(cuid())
  userId        String
  deviceId      String        @unique
  pushToken     String?
  lastSeenAt    DateTime      @default(now())
  createdAt     DateTime      @default(now())

  user          User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  refreshTokens RefreshToken[]
}

model RefreshToken {
  id         String   @id @default(cuid())
  userId     String
  deviceId   String
  tokenHash  String   @unique
  expiresAt  DateTime
  createdAt  DateTime @default(now())

  user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  device     Device   @relation(fields: [deviceId], references: [id], onDelete: Cascade)
}

model Partnership {
  id          String            @id @default(cuid())
  requesterId String
  targetId    String
  status      PartnershipStatus @default(PENDING)
  createdAt   DateTime          @default(now())
  updatedAt   DateTime          @updatedAt

  requester   User              @relation("requester", fields: [requesterId], references: [id])
  target      User              @relation("target", fields: [targetId], references: [id])

  @@unique([requesterId, targetId])
}

enum PartnershipStatus {
  PENDING
  ACTIVE
  REVOKED
}

Step 3: Run migration on dev DB

bash
pnpm prisma:migrate
# When prompted for migration name: enter "init"

Expected: Your database is now in sync with your schema.

Step 4: Run migration on test DB

bash
DATABASE_URL="postgresql://localhost:5432/avelia_test" pnpm prisma:migrate:deploy

Expected: All migrations have been successfully applied.

Step 5: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/prisma/
git commit -m "feat(backend): add Prisma schema (User, Device, RefreshToken, Partnership)"

Task 4: PrismaModule + PrismaService

Files:

  • Create: src/prisma/prisma.service.ts
  • Create: src/prisma/prisma.module.ts

Step 1: Write the unit test first

Create src/prisma/prisma.service.spec.ts:

typescript
import { Test } from '@nestjs/testing';
import { PrismaService } from './prisma.service';

describe('PrismaService', () => {
  let service: PrismaService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [PrismaService],
    }).compile();
    service = module.get<PrismaService>(PrismaService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });
});

Step 2: Run test to verify it fails

bash
pnpm test -- --testPathPattern=prisma.service

Expected: FAIL — Cannot find module './prisma.service'

Step 3: Create src/prisma/prisma.service.ts

typescript
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

Step 4: Create src/prisma/prisma.module.ts

typescript
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Step 5: Run test to verify it passes

bash
pnpm test -- --testPathPattern=prisma.service

Expected: PASS

Step 6: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/prisma/
git commit -m "feat(backend): add PrismaService and PrismaModule"

Task 5: App bootstrap (AppModule, main.ts, health endpoint)

Files:

  • Create: src/health/health.controller.ts
  • Create: src/app.module.ts
  • Create: src/main.ts

Step 1: Create src/health/health.controller.ts

typescript
import { Controller, Get } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get()
  check() {
    return { status: 'ok', timestamp: new Date().toISOString() };
  }
}

Step 2: Create src/app.module.ts

typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { HealthController } from './health/health.controller';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
  ],
  controllers: [HealthController],
})
export class AppModule {}

Step 3: Create src/main.ts

typescript
import { NestFactory, Reflector } from '@nestjs/core';
import { ValidationPipe, ClassSerializerInterceptor } from '@nestjs/common';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  app.useGlobalFilters(new GlobalExceptionFilter());
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

  app.enableCors({ origin: process.env.CORS_ORIGIN ?? '*' });

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

Step 4: Create src/common/filters/global-exception.filter.ts

typescript
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(GlobalExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';

    if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
      this.logger.error(exception);
    }

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}

Step 5: Verify server starts

bash
pnpm start:dev

Expected: Application is running on: http://[::1]:3000

Then in another terminal:

bash
curl http://localhost:3000/health

Expected: {"status":"ok","timestamp":"..."}

Stop the server with Ctrl+C.

Step 6: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/
git commit -m "feat(backend): add AppModule, main.ts bootstrap, health endpoint"

Task 6: UsersModule

Files:

  • Create: src/users/users.service.ts
  • Create: src/users/users.service.spec.ts
  • Create: src/users/users.module.ts

Step 1: Write the failing unit test

Create src/users/users.service.spec.ts:

typescript
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';

describe('UsersService', () => {
  let service: UsersService;
  let prisma: DeepMockProxy<PrismaService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [UsersService, PrismaService],
    })
      .overrideProvider(PrismaService)
      .useValue(mockDeep<PrismaService>())
      .compile();

    service = module.get<UsersService>(UsersService);
    prisma = module.get(PrismaService);
  });

  describe('findByEmail', () => {
    it('returns user when found', async () => {
      const user = { id: 'u1', email: 'a@b.com', passwordHash: 'h', argon2Salt: 's', createdAt: new Date(), updatedAt: new Date() };
      prisma.user.findUnique.mockResolvedValue(user as any);
      const result = await service.findByEmail('a@b.com');
      expect(result).toEqual(user);
      expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: 'a@b.com' } });
    });

    it('returns null when not found', async () => {
      prisma.user.findUnique.mockResolvedValue(null);
      const result = await service.findByEmail('notexist@b.com');
      expect(result).toBeNull();
    });
  });

  describe('create', () => {
    it('creates and returns user', async () => {
      const input = { email: 'a@b.com', passwordHash: 'h', argon2Salt: 's' };
      const user = { id: 'u1', ...input, createdAt: new Date(), updatedAt: new Date() };
      prisma.user.create.mockResolvedValue(user as any);
      const result = await service.create(input);
      expect(result).toEqual(user);
    });
  });
});

Step 2: Run test to verify it fails

bash
pnpm test -- --testPathPattern=users.service

Expected: FAIL — Cannot find module './users.service'

Step 3: Create src/users/users.service.ts

typescript
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

interface CreateUserInput {
  email: string;
  passwordHash: string;
  argon2Salt: string;
}

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  findByEmail(email: string) {
    return this.prisma.user.findUnique({ where: { email } });
  }

  findById(id: string) {
    return this.prisma.user.findUnique({ where: { id } });
  }

  create(input: CreateUserInput) {
    return this.prisma.user.create({ data: input });
  }
}

Step 4: Create src/users/users.module.ts

typescript
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Step 5: Run test to verify it passes

bash
pnpm test -- --testPathPattern=users.service

Expected: PASS — 3 tests passing

Step 6: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/users/
git commit -m "feat(backend): add UsersModule with findByEmail, findById, create"

Task 7: DevicesModule

Files:

  • Create: src/devices/devices.service.ts
  • Create: src/devices/devices.service.spec.ts
  • Create: src/devices/devices.controller.ts
  • Create: src/devices/dto/register-device.dto.ts
  • Create: src/devices/devices.module.ts

Step 1: Write the failing unit test

Create src/devices/devices.service.spec.ts:

typescript
import { Test } from '@nestjs/testing';
import { DevicesService } from './devices.service';
import { PrismaService } from '../prisma/prisma.service';
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';

describe('DevicesService', () => {
  let service: DevicesService;
  let prisma: DeepMockProxy<PrismaService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [DevicesService, PrismaService],
    })
      .overrideProvider(PrismaService)
      .useValue(mockDeep<PrismaService>())
      .compile();

    service = module.get<DevicesService>(DevicesService);
    prisma = module.get(PrismaService);
  });

  describe('upsert', () => {
    it('upserts device and returns it', async () => {
      const device = { id: 'd1', userId: 'u1', deviceId: 'device-uuid', pushToken: null, lastSeenAt: new Date(), createdAt: new Date() };
      prisma.device.upsert.mockResolvedValue(device as any);

      const result = await service.upsert({ userId: 'u1', deviceId: 'device-uuid' });
      expect(result).toEqual(device);
      expect(prisma.device.upsert).toHaveBeenCalled();
    });
  });
});

Step 2: Run test to verify it fails

bash
pnpm test -- --testPathPattern=devices.service

Expected: FAIL

Step 3: Create src/devices/dto/register-device.dto.ts

typescript
import { IsOptional, IsString, IsUUID } from 'class-validator';

export class RegisterDeviceDto {
  @IsUUID()
  deviceId: string;

  @IsString()
  @IsOptional()
  pushToken?: string;
}

Step 4: Create src/devices/devices.service.ts

typescript
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

interface UpsertDeviceInput {
  userId: string;
  deviceId: string;
  pushToken?: string;
}

@Injectable()
export class DevicesService {
  constructor(private readonly prisma: PrismaService) {}

  upsert(input: UpsertDeviceInput) {
    return this.prisma.device.upsert({
      where: { deviceId: input.deviceId },
      create: {
        userId: input.userId,
        deviceId: input.deviceId,
        pushToken: input.pushToken,
        lastSeenAt: new Date(),
      },
      update: {
        userId: input.userId,
        pushToken: input.pushToken,
        lastSeenAt: new Date(),
      },
    });
  }
}

Step 5: Create src/devices/devices.controller.ts

typescript
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { DevicesService } from './devices.service';
import { RegisterDeviceDto } from './dto/register-device.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';

@Controller('auth/devices')
@UseGuards(JwtAuthGuard)
export class DevicesController {
  constructor(private readonly devicesService: DevicesService) {}

  @Post()
  register(@CurrentUser('sub') userId: string, @Body() dto: RegisterDeviceDto) {
    return this.devicesService.upsert({ userId, ...dto });
  }
}

Step 6: Create src/devices/devices.module.ts

typescript
import { Module } from '@nestjs/common';
import { DevicesService } from './devices.service';
import { DevicesController } from './devices.controller';

@Module({
  providers: [DevicesService],
  controllers: [DevicesController],
  exports: [DevicesService],
})
export class DevicesModule {}

Step 7: Run test to verify it passes

bash
pnpm test -- --testPathPattern=devices.service

Expected: PASS

Step 8: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/devices/
git commit -m "feat(backend): add DevicesModule with upsert"

Task 8: Auth DTOs + decorators

Files:

  • Create: src/auth/dto/register.dto.ts
  • Create: src/auth/dto/login.dto.ts
  • Create: src/auth/dto/refresh.dto.ts
  • Create: src/auth/dto/auth-response.dto.ts
  • Create: src/common/decorators/current-user.decorator.ts
  • Create: src/common/decorators/public.decorator.ts

Step 1: Create DTOs

src/auth/dto/register.dto.ts:

typescript
import { IsEmail, IsString, MinLength } from 'class-validator';

export class RegisterDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsString()
  deviceId: string;
}

src/auth/dto/login.dto.ts:

typescript
import { IsEmail, IsString } from 'class-validator';

export class LoginDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;

  @IsString()
  deviceId: string;
}

src/auth/dto/refresh.dto.ts:

typescript
import { IsString } from 'class-validator';

export class RefreshDto {
  @IsString()
  refreshToken: string;

  @IsString()
  deviceId: string;
}

src/auth/dto/auth-response.dto.ts:

typescript
export class AuthResponseDto {
  accessToken: string;
  refreshToken: string;
  argon2Salt: string;
}

Step 2: Create decorators

src/common/decorators/current-user.decorator.ts:

typescript
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (field: string | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return field ? user?.[field] : user;
  },
);

src/common/decorators/public.decorator.ts:

typescript
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

Step 3: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/auth/dto/ avelia-backend/src/common/decorators/
git commit -m "feat(backend): add auth DTOs and decorators"

Task 9: JWT strategy + guard

Files:

  • Create: src/auth/strategies/jwt.strategy.ts
  • Create: src/auth/strategies/jwt-refresh.strategy.ts
  • Create: src/auth/guards/jwt-auth.guard.ts

Step 1: Create src/auth/strategies/jwt.strategy.ts

typescript
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.get<string>('JWT_SECRET')!,
    });
  }

  validate(payload: { sub: string; email: string }) {
    return payload; // attached to request.user
  }
}

Step 2: Create src/auth/strategies/jwt-refresh.strategy.ts

typescript
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
      ignoreExpiration: false,
      secretOrKey: config.get<string>('JWT_REFRESH_SECRET')!,
      passReqToCallback: true,
    });
  }

  validate(req: Request, payload: { sub: string; deviceId: string }) {
    const refreshToken = req.body?.refreshToken as string;
    return { ...payload, refreshToken };
  }
}

Step 3: Create src/auth/guards/jwt-auth.guard.ts

typescript
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }
}

Step 4: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/auth/strategies/ avelia-backend/src/auth/guards/
git commit -m "feat(backend): add JWT strategies and JwtAuthGuard"

Task 10: AuthService + AuthController + AuthModule (TDD — E2E)

Files:

  • Create: test/auth.e2e-spec.ts
  • Create: src/auth/auth.service.ts
  • Create: src/auth/auth.controller.ts
  • Create: src/auth/auth.module.ts
  • Modify: src/app.module.ts

Step 1: Write the failing E2E tests

Create test/auth.e2e-spec.ts:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { GlobalExceptionFilter } from '../src/common/filters/global-exception.filter';

describe('Auth (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
    app.useGlobalFilters(new GlobalExceptionFilter());
    await app.init();

    prisma = module.get<PrismaService>(PrismaService);
  });

  beforeEach(async () => {
    await prisma.refreshToken.deleteMany();
    await prisma.device.deleteMany();
    await prisma.user.deleteMany();
  });

  afterAll(async () => {
    await app.close();
  });

  const REGISTER_PAYLOAD = {
    email: 'test@avelia.com',
    password: 'Password123!',
    deviceId: '550e8400-e29b-41d4-a716-446655440000',
  };

  describe('POST /auth/register', () => {
    it('creates user and returns tokens + argon2Salt', async () => {
      const res = await request(app.getHttpServer())
        .post('/auth/register')
        .send(REGISTER_PAYLOAD)
        .expect(201);

      expect(res.body).toHaveProperty('accessToken');
      expect(res.body).toHaveProperty('refreshToken');
      expect(res.body).toHaveProperty('argon2Salt');
      expect(typeof res.body.argon2Salt).toBe('string');
      expect(res.body.argon2Salt.length).toBe(64); // 32 bytes hex
    });

    it('returns 409 on duplicate email', async () => {
      await request(app.getHttpServer()).post('/auth/register').send(REGISTER_PAYLOAD).expect(201);
      await request(app.getHttpServer()).post('/auth/register').send(REGISTER_PAYLOAD).expect(409);
    });

    it('returns 400 on invalid body', async () => {
      await request(app.getHttpServer())
        .post('/auth/register')
        .send({ email: 'not-an-email', password: 'short' })
        .expect(400);
    });
  });

  describe('POST /auth/login', () => {
    beforeEach(async () => {
      await request(app.getHttpServer()).post('/auth/register').send(REGISTER_PAYLOAD);
    });

    it('returns tokens + argon2Salt for valid credentials', async () => {
      const res = await request(app.getHttpServer())
        .post('/auth/login')
        .send({ email: REGISTER_PAYLOAD.email, password: REGISTER_PAYLOAD.password, deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(200);

      expect(res.body).toHaveProperty('accessToken');
      expect(res.body).toHaveProperty('refreshToken');
      expect(res.body).toHaveProperty('argon2Salt');
    });

    it('returns 401 for wrong password', async () => {
      await request(app.getHttpServer())
        .post('/auth/login')
        .send({ email: REGISTER_PAYLOAD.email, password: 'WrongPassword!', deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(401);
    });

    it('returns 401 for unknown email', async () => {
      await request(app.getHttpServer())
        .post('/auth/login')
        .send({ email: 'nobody@avelia.com', password: 'Password123!', deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(401);
    });
  });

  describe('POST /auth/refresh', () => {
    let refreshToken: string;

    beforeEach(async () => {
      const res = await request(app.getHttpServer()).post('/auth/register').send(REGISTER_PAYLOAD);
      refreshToken = res.body.refreshToken;
    });

    it('returns new access token and rotates refresh token', async () => {
      const res = await request(app.getHttpServer())
        .post('/auth/refresh')
        .send({ refreshToken, deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(200);

      expect(res.body).toHaveProperty('accessToken');
      expect(res.body).toHaveProperty('refreshToken');
      expect(res.body.refreshToken).not.toBe(refreshToken);
    });

    it('returns 401 if refresh token is reused after rotation', async () => {
      await request(app.getHttpServer())
        .post('/auth/refresh')
        .send({ refreshToken, deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(200);

      await request(app.getHttpServer())
        .post('/auth/refresh')
        .send({ refreshToken, deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(401);
    });
  });

  describe('POST /auth/logout', () => {
    let accessToken: string;
    let refreshToken: string;

    beforeEach(async () => {
      const res = await request(app.getHttpServer()).post('/auth/register').send(REGISTER_PAYLOAD);
      accessToken = res.body.accessToken;
      refreshToken = res.body.refreshToken;
    });

    it('invalidates the refresh token', async () => {
      await request(app.getHttpServer())
        .post('/auth/logout')
        .set('Authorization', `Bearer ${accessToken}`)
        .send({ refreshToken, deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(200);

      // Refresh should now fail
      await request(app.getHttpServer())
        .post('/auth/refresh')
        .send({ refreshToken, deviceId: REGISTER_PAYLOAD.deviceId })
        .expect(401);
    });
  });
});

Step 2: Run tests to verify they all fail

bash
pnpm test:e2e

Expected: FAIL — multiple errors about missing modules

Step 3: Create src/auth/auth.service.ts

typescript
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'crypto';
import * as argon2 from '@node-rs/argon2';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { DevicesService } from '../devices/devices.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { AuthResponseDto } from './dto/auth-response.dto';

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly usersService: UsersService,
    private readonly devicesService: DevicesService,
    private readonly jwtService: JwtService,
    private readonly config: ConfigService,
  ) {}

  async register(dto: RegisterDto): Promise<AuthResponseDto> {
    const existing = await this.usersService.findByEmail(dto.email);
    if (existing) throw new ConflictException('Email already registered');

    const passwordHash = await argon2.hash(dto.password);
    const argon2Salt = randomBytes(32).toString('hex');

    const user = await this.usersService.create({ email: dto.email, passwordHash, argon2Salt });
    await this.devicesService.upsert({ userId: user.id, deviceId: dto.deviceId });

    const tokens = await this.generateTokens(user.id, dto.email, dto.deviceId);
    return { ...tokens, argon2Salt };
  }

  async login(dto: LoginDto): Promise<AuthResponseDto> {
    const user = await this.usersService.findByEmail(dto.email);
    if (!user) throw new UnauthorizedException('Invalid credentials');

    const valid = await argon2.verify(user.passwordHash, dto.password);
    if (!valid) throw new UnauthorizedException('Invalid credentials');

    await this.devicesService.upsert({ userId: user.id, deviceId: dto.deviceId });
    const tokens = await this.generateTokens(user.id, user.email, dto.deviceId);
    return { ...tokens, argon2Salt: user.argon2Salt };
  }

  async refresh(refreshToken: string, deviceId: string): Promise<Omit<AuthResponseDto, 'argon2Salt'>> {
    // Decode without verification first to get the jti for lookup
    let payload: { sub: string; email: string; deviceId: string; jti: string };
    try {
      payload = this.jwtService.verify(refreshToken, {
        secret: this.config.get<string>('JWT_REFRESH_SECRET'),
      });
    } catch {
      throw new UnauthorizedException();
    }

    const stored = await this.prisma.refreshToken.findFirst({
      where: { userId: payload.sub, deviceId, expiresAt: { gt: new Date() } },
    });
    if (!stored) throw new UnauthorizedException();

    const valid = await argon2.verify(stored.tokenHash, refreshToken);
    if (!valid) throw new UnauthorizedException();

    await this.prisma.refreshToken.delete({ where: { id: stored.id } });
    return this.generateTokens(payload.sub, payload.email, deviceId);
  }

  async logout(userId: string, deviceId: string): Promise<void> {
    await this.prisma.refreshToken.deleteMany({ where: { userId, deviceId } });
  }

  private async generateTokens(userId: string, email: string, deviceId: string) {
    const jti = randomBytes(16).toString('hex');

    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(
        { sub: userId, email },
        { secret: this.config.get('JWT_SECRET'), expiresIn: this.config.get('JWT_EXPIRES_IN') },
      ),
      this.jwtService.signAsync(
        { sub: userId, email, deviceId, jti },
        { secret: this.config.get('JWT_REFRESH_SECRET'), expiresIn: this.config.get('JWT_REFRESH_EXPIRES_IN') },
      ),
    ]);

    const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d';
    const days = parseInt(expiresIn.replace('d', ''), 10);
    const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);

    await this.prisma.refreshToken.create({
      data: {
        userId,
        deviceId,
        tokenHash: await argon2.hash(refreshToken),
        expiresAt,
      },
    });

    return { accessToken, refreshToken };
  }
}

Step 4: Create src/auth/auth.controller.ts

typescript
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshDto } from './dto/refresh.dto';
import { Public } from '../common/decorators/public.decorator';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public()
  @Post('register')
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  @Public()
  @Post('login')
  @HttpCode(200)
  login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }

  @Public()
  @Post('refresh')
  @HttpCode(200)
  refresh(@Body() dto: RefreshDto) {
    return this.authService.refresh(dto.refreshToken, dto.deviceId);
  }

  @UseGuards(JwtAuthGuard)
  @Post('logout')
  @HttpCode(200)
  logout(
    @CurrentUser('sub') userId: string,
    @Body() dto: RefreshDto,
  ) {
    return this.authService.logout(userId, dto.deviceId);
  }
}

Step 5: Create src/auth/auth.module.ts

typescript
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersModule } from '../users/users.module';
import { DevicesModule } from '../devices/devices.module';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({}),
    UsersModule,
    DevicesModule,
  ],
  providers: [AuthService, JwtStrategy, JwtRefreshStrategy, JwtAuthGuard],
  controllers: [AuthController],
  exports: [JwtAuthGuard],
})
export class AuthModule {}

Step 6: Update src/app.module.ts

Add AuthModule and DevicesModule imports:

typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { DevicesModule } from './devices/devices.module';
import { HealthController } from './health/health.controller';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    UsersModule,
    DevicesModule,
    AuthModule,
  ],
  controllers: [HealthController],
})
export class AppModule {}

Step 7: Run E2E tests

bash
pnpm test:e2e

Expected: All tests PASS (12 tests)

If any test fails, read the error carefully — most common issues:

  • Database not running: pg_ctl start or brew services start postgresql
  • Migration not applied to test DB: run DATABASE_URL="postgresql://localhost:5432/avelia_test" pnpm prisma:migrate:deploy
  • JWT_SECRET not set in .env.test: verify .env.test exists

Step 8: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/auth/ avelia-backend/test/
git commit -m "feat(backend): add AuthModule with register/login/refresh/logout + E2E tests"

Task 11: Wire JwtAuthGuard globally + smoke test

Files:

  • Modify: src/main.ts

Step 1: Register JwtAuthGuard as a global guard

In src/main.ts, add after app.useGlobalFilters(...):

typescript
import { APP_GUARD } from '@nestjs/core';
// In AppModule providers (NOT main.ts — add to app.module.ts):

Actually, register the guard in app.module.ts so it has access to the Reflector (required by JwtAuthGuard):

In src/app.module.ts, add to providers:

typescript
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';

// Inside @Module:
providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },
],

Remove @UseGuards(JwtAuthGuard) from AuthController.logout since the guard is now global (keep the @Public() on the public endpoints).

Step 2: Run E2E tests to verify still passing

bash
pnpm test:e2e

Expected: All tests still PASS

Step 3: Manual smoke test

bash
pnpm start:dev
bash
# Register
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"smoke@test.com","password":"Password123!","deviceId":"550e8400-e29b-41d4-a716-446655440000"}'
# Should return accessToken, refreshToken, argon2Salt

# Health (public)
curl http://localhost:3000/health
# Should return {"status":"ok","timestamp":"..."}

Step 4: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-backend/src/
git commit -m "feat(backend): register JwtAuthGuard globally via APP_GUARD"

Task 12: Update CLAUDE.md + backend memory

Files:

  • Modify: avelia-backend/CLAUDE.md — update commands to reflect pnpm
  • Modify: memory/MEMORY.md

Step 1: Verify all commands in avelia-backend/CLAUDE.md use pnpm not npm

Read avelia-backend/CLAUDE.md and replace all npm run with pnpm run and npm install with pnpm install.

Step 2: Update MEMORY.md to note backend is scaffolded

Append to /Users/antonanders/.claude/projects/-Volumes-exSSD-dev-baby-parents-app/memory/MEMORY.md:

markdown

## Backend Status
- Scaffolded: NestJS 11 + pnpm + Prisma 5 + JWT auth + Argon2
- Auth endpoints: POST /auth/register, /auth/login, /auth/refresh, /auth/logout
- Device registration: POST /auth/devices
- E2E tests: passing (test/auth.e2e-spec.ts)
- DB: avelia_dev (dev), avelia_test (test)
- argon2Salt: 32-byte hex, stored per-user, sent at login/register for Flutter MEK derivation

Step 3: Final test run

bash
pnpm test && pnpm test:e2e

Expected: All unit + E2E tests PASS

Step 4: Final commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add .
git commit -m "chore(backend): update CLAUDE.md and project memory after init"

Private by design.