Appearance
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-backendTask 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 initExpected: 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.dbStep 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 \
rxjsStep 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-extendedStep 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 testjson
{
"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=developmentStep 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
EOFStep 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 postgresqlExpected: 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:deployExpected: 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.serviceExpected: 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.serviceExpected: 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:devExpected: Application is running on: http://[::1]:3000
Then in another terminal:
bash
curl http://localhost:3000/healthExpected: {"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.serviceExpected: 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.serviceExpected: 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.serviceExpected: 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.serviceExpected: 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:e2eExpected: 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:e2eExpected: All tests PASS (12 tests)
If any test fails, read the error carefully — most common issues:
- Database not running:
pg_ctl startorbrew services start postgresql - Migration not applied to test DB: run
DATABASE_URL="postgresql://localhost:5432/avelia_test" pnpm prisma:migrate:deploy JWT_SECRETnot set in.env.test: verify.env.testexists
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:e2eExpected: All tests still PASS
Step 3: Manual smoke test
bash
pnpm start:devbash
# 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 derivationStep 3: Final test run
bash
pnpm test && pnpm test:e2eExpected: 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"