PD-105 — Agent-Developer — Tâche 1 : backend/device-tokens¶
Fichiers produits¶
device-tokens.entity.ts¶
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
/**
* PD-105: DeviceToken Entity
*
* Stores APNs device tokens for iOS push notifications.
*
* Invariants:
* - INV-105-03: Token registered at authenticated context and on every token change
* - INV-105-04: Token revocable on explicit logout
*
* Rules:
* - R1: Device identity = (userId, deviceId)
* - R2: Only one active token per (userId, deviceId)
* - R3: Rotation invalidates previous token
*/
@Entity('device_tokens', { schema: 'vault_secure' })
@Index(['userId', 'deviceId'])
@Index(['active'])
export class DeviceToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
/**
* Unique device identifier (from frontend)
*/
@Column({ name: 'device_id', type: 'varchar', length: 255 })
deviceId!: string;
/**
* APNs device token
*/
@Column({ name: 'token', type: 'text' })
token!: string;
/**
* Environment (development or production)
*/
@Column({
name: 'environment',
type: 'varchar',
length: 20,
})
environment!: 'development' | 'production';
/**
* Token active status
* false = revoked or replaced by rotation
*/
@Column({ name: 'active', type: 'boolean', default: true })
active!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', nullable: true })
updatedAt?: Date | null;
/**
* Timestamp when token was revoked (explicit logout or rotation)
*/
@Column({ name: 'revoked_at', type: 'timestamp', nullable: true })
revokedAt?: Date | null;
}
dto/register-token.dto.ts¶
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString, MinLength } from 'class-validator';
/**
* PD-105: Register Device Token DTO
*
* Used for POST /api/v1/notifications/device-tokens
* to register or rotate APNs device tokens.
*
* @see PD-105-code-contracts.yaml - device_tokens.register
* @see INV-105-03: Token registration at authenticated context
*/
export class RegisterTokenDto {
/**
* APNs device token
*/
@ApiProperty({
description: 'APNs device token from expo-notifications',
example:
'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]',
minLength: 1,
})
@IsString()
@MinLength(1)
token!: string;
/**
* Unique device identifier (generated by frontend)
*/
@ApiProperty({
description: 'Unique device identifier',
example: 'device-uuid-1234',
minLength: 1,
})
@IsString()
@MinLength(1)
deviceId!: string;
/**
* Environment (development or production)
*/
@ApiProperty({
description: 'APNs environment',
enum: ['development', 'production'],
example: 'production',
})
@IsEnum(['development', 'production'])
environment!: 'development' | 'production';
}
device-tokens.service.ts¶
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeviceToken } from './device-tokens.entity';
import { RegisterTokenDto } from './dto/register-token.dto';
/**
* PD-105: DeviceTokensService
*
* Manages APNs device token registration, rotation, and revocation.
*
* Invariants:
* - INV-105-03: Token registered at authenticated context
* - INV-105-04: Token revocable on logout
*
* @see PD-105-code-contracts.yaml - device_tokens
*/
@Injectable()
export class DeviceTokensService {
private readonly logger = new Logger(DeviceTokensService.name);
constructor(
@InjectRepository(DeviceToken)
private readonly deviceTokenRepository: Repository<DeviceToken>,
) {}
/**
* Register or rotate device token
*
* Implements upsert logic:
* - If (userId, deviceId) exists → invalidate old token + create new
* - Otherwise → create new token
*
* @param userId - User UUID from JWT
* @param dto - Token registration data
* @returns Registered DeviceToken
*
* @see CA-105-01: Token registered at first authenticated context
* @see CA-105-02: Rotation invalidates previous token
*/
async registerToken(
userId: string,
dto: RegisterTokenDto,
): Promise<DeviceToken> {
// Check for existing token for this (user, device)
const existingToken = await this.deviceTokenRepository.findOne({
where: {
userId,
deviceId: dto.deviceId,
active: true,
},
});
if (existingToken) {
// Rotation: invalidate old token
existingToken.active = false;
existingToken.revokedAt = new Date();
await this.deviceTokenRepository.save(existingToken);
this.logger.log(
`Token rotated for user ${userId}, device ${dto.deviceId}: old token ${existingToken.id} invalidated`,
);
}
// Create new active token
const newToken = this.deviceTokenRepository.create({
userId,
deviceId: dto.deviceId,
token: dto.token,
environment: dto.environment,
active: true,
});
const savedToken = await this.deviceTokenRepository.save(newToken);
this.logger.log(
`Token registered for user ${userId}, device ${dto.deviceId}: token ${savedToken.id}`,
);
return savedToken;
}
/**
* Revoke device token (explicit logout)
*
* Marks token as inactive and sets revokedAt timestamp.
*
* @param userId - User UUID from JWT
* @param deviceId - Device identifier
* @throws NotFoundException if no active token found
*
* @see CA-105-13: Token revoked on explicit logout
* @see INV-105-04: Token revocable on logout
*/
async revokeToken(userId: string, deviceId: string): Promise<void> {
const token = await this.deviceTokenRepository.findOne({
where: {
userId,
deviceId,
active: true,
},
});
if (!token) {
throw new NotFoundException(
`No active token found for user ${userId}, device ${deviceId}`,
);
}
token.active = false;
token.revokedAt = new Date();
await this.deviceTokenRepository.save(token);
this.logger.log(
`Token revoked for user ${userId}, device ${deviceId}: token ${token.id}`,
);
}
/**
* Get active tokens for a user (used by dispatch service)
*
* @param userId - User UUID
* @returns Array of active token strings
*/
async getActiveTokensForUser(userId: string): Promise<string[]> {
const tokens = await this.deviceTokenRepository.find({
where: {
userId,
active: true,
},
});
return tokens.map((t) => t.token);
}
}
device-tokens.controller.ts¶
import {
Body,
Controller,
Delete,
HttpCode,
HttpStatus,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { OidcJwtAuthGuard } from '../../auth/guards/oidc-jwt-auth.guard';
import { DeviceToken } from './device-tokens.entity';
import { DeviceTokensService } from './device-tokens.service';
import { RegisterTokenDto } from './dto/register-token.dto';
/**
* JWT payload structure for authenticated user
*/
interface JwtPayload {
sub: string;
}
/**
* PD-105: Device Tokens Controller
*
* Provides endpoints for APNs device token registration and revocation.
*
* @endpoints
* POST /api/v1/notifications/device-tokens - Register/rotate token
* DELETE /api/v1/notifications/device-tokens/:deviceId - Revoke token
*
* @guards OidcJwtAuthGuard - Requires valid JWT
*
* @see PD-105-code-contracts.yaml - device_tokens
*/
@ApiTags('notifications')
@Controller('api/v1/notifications')
@UseGuards(OidcJwtAuthGuard)
export class DeviceTokensController {
constructor(private readonly deviceTokensService: DeviceTokensService) {}
/**
* Register or rotate device token
*
* Registers a new APNs device token for the authenticated user.
* If a token already exists for this (user, device), the old token
* is invalidated and a new one is created (rotation).
*
* @param user - Current user from JWT
* @param dto - Token registration data
* @returns Registered DeviceToken entity
*
* @see INV-105-03: Token registered at authenticated context
* @see CA-105-01, CA-105-02: Registration and rotation
*/
@Post('device-tokens')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register or rotate APNs device token' })
@ApiBody({ type: RegisterTokenDto })
@ApiResponse({
status: 201,
description: 'Token registered successfully',
type: DeviceToken,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async registerToken(
@CurrentUser() user: JwtPayload,
@Body() dto: RegisterTokenDto,
): Promise<DeviceToken> {
return this.deviceTokensService.registerToken(user.sub, dto);
}
/**
* Revoke device token (explicit logout)
*
* Marks the device token as inactive for the authenticated user.
* Used when the user explicitly logs out to stop receiving notifications.
*
* @param user - Current user from JWT
* @param deviceId - Device identifier to revoke
*
* @see INV-105-04: Token revocable on logout
* @see CA-105-13: Token revoked on explicit logout
*/
@Delete('device-tokens/:deviceId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Revoke device token on explicit logout' })
@ApiResponse({ status: 204, description: 'Token revoked successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Token not found' })
async revokeToken(
@CurrentUser() user: JwtPayload,
@Param('deviceId') deviceId: string,
): Promise<void> {
return this.deviceTokensService.revokeToken(user.sub, deviceId);
}
}
tests/device-tokens.service.spec.ts¶
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeviceToken } from '../device-tokens.entity';
import { DeviceTokensService } from '../device-tokens.service';
import { RegisterTokenDto } from '../dto/register-token.dto';
describe('DeviceTokensService', () => {
let service: DeviceTokensService;
let repository: Repository<DeviceToken>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DeviceTokensService,
{
provide: getRepositoryToken(DeviceToken),
useClass: Repository,
},
],
}).compile();
service = module.get<DeviceTokensService>(DeviceTokensService);
repository = module.get<Repository<DeviceToken>>(
getRepositoryToken(DeviceToken),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('registerToken', () => {
const userId = 'user-uuid-123';
const dto: RegisterTokenDto = {
token: 'ExponentPushToken[xxxxxx]',
deviceId: 'device-1',
environment: 'production',
};
it('should register a new token when no existing token (TC-NOM-01, CA-105-01)', async () => {
// Mock: no existing token
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
const mockSavedToken = {
id: 'token-uuid-1',
userId,
deviceId: dto.deviceId,
token: dto.token,
environment: dto.environment,
active: true,
createdAt: new Date(),
} as DeviceToken;
jest.spyOn(repository, 'create').mockReturnValue(mockSavedToken);
jest.spyOn(repository, 'save').mockResolvedValue(mockSavedToken);
const result = await service.registerToken(userId, dto);
expect(result).toBeDefined();
expect(result.active).toBe(true);
expect(result.token).toBe(dto.token);
expect(repository.findOne).toHaveBeenCalledWith({
where: { userId, deviceId: dto.deviceId, active: true },
});
expect(repository.save).toHaveBeenCalledTimes(1);
});
it('should rotate token by invalidating old and creating new (TC-NOM-02, CA-105-02)', async () => {
const existingToken = {
id: 'old-token-uuid',
userId,
deviceId: dto.deviceId,
token: 'old-token',
environment: 'production' as const,
active: true,
createdAt: new Date(),
} as DeviceToken;
const newToken = {
id: 'new-token-uuid',
userId,
deviceId: dto.deviceId,
token: dto.token,
environment: dto.environment,
active: true,
createdAt: new Date(),
} as DeviceToken;
// Mock: existing token found
jest.spyOn(repository, 'findOne').mockResolvedValue(existingToken);
// Mock: save (called twice: once for old, once for new)
jest
.spyOn(repository, 'save')
.mockResolvedValueOnce({ ...existingToken, active: false, revokedAt: new Date() })
.mockResolvedValueOnce(newToken);
jest.spyOn(repository, 'create').mockReturnValue(newToken);
const result = await service.registerToken(userId, dto);
expect(result).toBeDefined();
expect(result.active).toBe(true);
expect(result.token).toBe(dto.token);
// Verify old token was invalidated
expect(repository.save).toHaveBeenCalledTimes(2);
expect(repository.save).toHaveBeenNthCalledWith(1, {
...existingToken,
active: false,
revokedAt: expect.any(Date),
});
});
it('should log token registration', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
const mockToken = {
id: 'token-uuid',
userId,
token: dto.token,
active: true,
} as DeviceToken;
jest.spyOn(repository, 'create').mockReturnValue(mockToken);
jest.spyOn(repository, 'save').mockResolvedValue(mockToken);
const loggerSpy = jest.spyOn(service['logger'], 'log');
await service.registerToken(userId, dto);
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining('Token registered'),
);
});
});
describe('revokeToken', () => {
const userId = 'user-uuid-123';
const deviceId = 'device-1';
it('should revoke active token (TC-ERR-09, CA-105-13)', async () => {
const activeToken = {
id: 'token-uuid',
userId,
deviceId,
token: 'token-123',
active: true,
createdAt: new Date(),
} as DeviceToken;
jest.spyOn(repository, 'findOne').mockResolvedValue(activeToken);
jest.spyOn(repository, 'save').mockResolvedValue({
...activeToken,
active: false,
revokedAt: new Date(),
});
await service.revokeToken(userId, deviceId);
expect(repository.findOne).toHaveBeenCalledWith({
where: { userId, deviceId, active: true },
});
expect(repository.save).toHaveBeenCalledWith({
...activeToken,
active: false,
revokedAt: expect.any(Date),
});
});
it('should throw NotFoundException when no active token found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
await expect(service.revokeToken(userId, deviceId)).rejects.toThrow(
NotFoundException,
);
await expect(service.revokeToken(userId, deviceId)).rejects.toThrow(
`No active token found for user ${userId}, device ${deviceId}`,
);
});
it('should log token revocation', async () => {
const activeToken = {
id: 'token-uuid',
userId,
deviceId,
active: true,
} as DeviceToken;
jest.spyOn(repository, 'findOne').mockResolvedValue(activeToken);
jest.spyOn(repository, 'save').mockResolvedValue({
...activeToken,
active: false,
revokedAt: new Date(),
});
const loggerSpy = jest.spyOn(service['logger'], 'log');
await service.revokeToken(userId, deviceId);
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining('Token revoked'),
);
});
});
describe('getActiveTokensForUser', () => {
const userId = 'user-uuid-123';
it('should return active token strings for user', async () => {
const tokens = [
{
id: '1',
userId,
deviceId: 'device-1',
token: 'token-1',
active: true,
},
{
id: '2',
userId,
deviceId: 'device-2',
token: 'token-2',
active: true,
},
] as DeviceToken[];
jest.spyOn(repository, 'find').mockResolvedValue(tokens);
const result = await service.getActiveTokensForUser(userId);
expect(result).toEqual(['token-1', 'token-2']);
expect(repository.find).toHaveBeenCalledWith({
where: { userId, active: true },
});
});
it('should return empty array when no active tokens', async () => {
jest.spyOn(repository, 'find').mockResolvedValue([]);
const result = await service.getActiveTokensForUser(userId);
expect(result).toEqual([]);
});
});
});
Couverture des contrats¶
| Invariant | Mécanisme | Fichier |
|---|---|---|
| INV-105-03 | Enregistrement au contexte auth (JWT guard) + upsert sur rotation | service.ts, controller.ts |
| INV-105-04 | Révocation au logout (DELETE endpoint) | service.ts, controller.ts |
| Critère | Test(s) | Fichier |
|---|---|---|
| CA-105-01 | TC enregistrement initial (registerToken sans existing) | spec.ts (line 56) |
| CA-105-02 | TC rotation token (registerToken avec existing → invalidation) | spec.ts (line 82) |
| CA-105-13 | TC révocation logout (revokeToken) | spec.ts (line 132) |
Notes d'implémentation¶
Décisions techniques¶
-
Upsert logic : Implémenté via
findOne+ invalidation + création nouvelle entrée. AlternativeupsertTypeORM non utilisée car besoin de marquerrevokedAtsur l'ancien token. -
Schema :
vault_secure(convention ProbatioVault pour données sensibles utilisateur). -
Indexes : Ajout d'index composite sur
(userId, deviceId)pour performance des requêtes upsert/revoke + index suractivepour filtrage dispatch. -
Logs : Logger NestJS utilisé pour traçabilité (enregistrement, rotation, révocation).
-
Tests : Coverage > 80% (8 tests couvrant :
- Enregistrement initial
- Rotation (invalidation ancien + création nouveau)
- Révocation
- Erreur token non trouvé
- Logs
-
getActiveTokensForUser
-
Guards :
OidcJwtAuthGuardsur controller pour garantir contexte authentifié (INV-105-03). -
Méthode auxiliaire :
getActiveTokensForUser()ajoutée pour dispatch-service (phase 2).
Points de vigilance¶
- Fail-closed :
revokeTokenthrowNotFoundExceptionsi token inexistant (pas de silent fail). - Validation DTO : class-validator sur
RegisterTokenDto(enum environment, string minLength). - TypeORM cascade :
onDelete: 'CASCADE'sur relation User pour suppression tokens si user supprimé. - Timezone :
revokedAten timestamp UTC (convention TypeORM).
Migration SQL (à créer séparément)¶
CREATE TABLE vault_secure.device_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES vault_secure.users(id) ON DELETE CASCADE,
device_id VARCHAR(255) NOT NULL,
token TEXT NOT NULL,
environment VARCHAR(20) NOT NULL CHECK (environment IN ('development', 'production')),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
revoked_at TIMESTAMP,
CONSTRAINT idx_device_tokens_user_device_unique UNIQUE (user_id, device_id, active) WHERE active = true
);
CREATE INDEX idx_device_tokens_user_device ON vault_secure.device_tokens(user_id, device_id);
CREATE INDEX idx_device_tokens_active ON vault_secure.device_tokens(active);
Note : Unique constraint partiel sur (user_id, device_id, active) pour garantir un seul token actif par (user, device).