PD-103 — Agent Developer — Module M9 : capture-ingest
1. Identite agent
- Agent : agent-developer (Agent B — Claude)
- Story : PD-103
- Module : M9 — capture-ingest
- Wave : 3 (depend de M10, M11, M13)
- Date : 2026-04-03
2. Resume
Module M9 implemente le controller et le service d'ingestion backend pour le flux de capture probatoire. Il est responsable de : 1. Le endpoint POST /documents/capture avec validation stricte des champs (§5.1) 2. La normalisation capture_id lowercase (INV-103-31) 3. La validation du skew timestamp_device (±300s, INV-103-12) 4. La coordination de l'idempotence (M10), de l'unwrap DEK (M11), et de la persistance ACID 5. La persistance de capture_events avec signature_status='PENDING_SIGNATURE' 6. L'ecriture dans le journal probatoire append-only capture_audit_log 7. Le mapping des erreurs metier vers les codes HTTP contractuels (202/200/400/409/422/429/503)
Fichiers a creer : - src/modules/capture/capture.controller.ts — Controller HTTP - src/modules/capture/services/capture-ingest.service.ts — Service d'ingestion - src/modules/capture/dto/create-capture.dto.ts — DTO de validation - src/modules/capture/dto/capture-response.dto.ts — DTO de reponse - src/modules/capture/entities/capture-event.entity.ts — Entite TypeORM - src/modules/capture/capture.module.ts — Module NestJS
Fichiers existants reutilises : - src/modules/documents/services/deposit.service.ts — Pattern transaction ACID + advisory lock - src/modules/capture/services/capture-idempotence.service.ts (M10) - src/modules/capture/services/kek-keyring.service.ts (M11) - src/database/migrations/1742400000000-PD103-CreateCaptureEvents.ts (M13) - src/database/migrations/1742400000001-PD103-CreateCaptureAuditLog.ts (M13)
3. Artefacts livres
| Fichier | Role | Lignes estimees |
src/modules/capture/capture.controller.ts | Controller POST /documents/capture, guard JWT, rate-limit, mapping reponses | ~120 |
src/modules/capture/services/capture-ingest.service.ts | Service d'ingestion : validation, normalisation, coordination M10/M11, persistance ACID, audit log | ~350 |
src/modules/capture/dto/create-capture.dto.ts | DTO validation §5.1 avec class-validator | ~180 |
src/modules/capture/dto/capture-response.dto.ts | DTO reponse 202/200 | ~40 |
src/modules/capture/entities/capture-event.entity.ts | Entite TypeORM pour capture_events | ~120 |
src/modules/capture/capture.module.ts | Module NestJS, imports, providers, exports | ~50 |
src/modules/capture/__tests__/capture-ingest.service.spec.ts | Tests contractuels + qualite | ~600 |
4. Architecture
4.1 Decisions architecturales
architectural_decisions:
- decision: "Transaction unique encompassante avec advisory lock (pattern DepositService PD-60)"
rationale: "Garantit l'atomicite ACID (INV-103-10) : idempotence check + unwrap DEK + INSERT capture_events + INSERT capture_audit_log dans une seule transaction. Le lock advisory serialise les requetes concurrentes sur le meme capture_id."
alternatives_considered:
- "Deux transactions separees (check + insert)"
- "Optimistic locking avec version column"
trade_offs: "Le lock advisory bloque les requetes concurrentes sur le meme capture_id pendant la duree de la transaction. Acceptable car la transaction est courte (<100ms). Pattern deja valide en production pour DepositService."
- decision: "Validation DTO via class-validator avec regexes contractuelles §5.1"
rationale: "class-validator est le standard NestJS pour la validation de payload HTTP. Les regexes sont extraites directement de la specification §5.1 pour garantir la conformite contractuelle."
alternatives_considered:
- "Zod schema validation"
- "Validation manuelle dans le service"
trade_offs: "class-validator est moins composable que Zod, mais natif NestJS et coherent avec le reste du backend ProbatioVault."
4.2 Diagramme de flux
sequenceDiagram
participant C as Controller
participant S as CaptureIngestService
participant V as Validation (DTO)
participant I as CaptureIdempotenceService (M10)
participant K as KekKeyringService (M11)
participant DB as PostgreSQL
participant Q as BullMQ (post-commit)
C->>V: ValidationPipe(CreateCaptureDto)
V-->>C: dto valide (ou 400)
C->>S: createCapture(dto, userId)
S->>S: normalise capture_id lowercase (INV-103-31)
S->>S: valide skew timestamp_device ±300s
S->>DB: dataSource.transaction(manager =>)
S->>I: acquireLockAndCheckIdempotence(manager, userId, fields)
Note over I: pg_advisory_xact_lock + lookup + fingerprint
alt action = IDEMPOTENT
I-->>S: {action: 'IDEMPOTENT', existingId, existingState}
S-->>C: 200 OK (etat existant)
else action = CREATE
I-->>S: {action: 'CREATE', payloadCanonicalSha256}
S->>K: unwrapDek(dekWrappedB64, kekId)
alt unwrap OK
K-->>S: {dekClear, kekIdUsed}
S->>S: zeroize(dekClear) immediatement
else 422 UNWRAP_DEK_FAILED
K-->>S: throw UnwrapDekFailedError
S-->>C: 422
else 503 KEY_SERVICE_UNAVAILABLE
K-->>S: throw KeyServiceUnavailableError
S-->>C: 503
end
S->>DB: INSERT capture_events (state='CAPTURED', signature_status='PENDING_SIGNATURE')
S->>DB: INSERT capture_audit_log (event_type='CAPTURE_INGESTED')
S->>DB: COMMIT
S->>Q: enqueue seal pipeline job (post-commit, async)
S-->>C: 202 Accepted
end
4.3 Dependances
| Dependance | Usage | Justification |
@nestjs/common | Controller, Injectable, Guards, Pipes, HttpException | Framework NestJS standard |
@nestjs/typeorm | InjectRepository, DataSource | ORM et transactions |
class-validator | Decorateurs de validation DTO | Standard NestJS, coherent avec le projet |
class-transformer | Transform decorators (lowercase, trim) | Normalisation payload |
typeorm | EntityManager, Repository, DataSource | Persistance ACID |
M10 CaptureIdempotenceService | Idempotence check | INV-103-37 |
M11 KekKeyringService | Unwrap DEK | INV-103-34 |
| Auth Guard JWT existant | Protection endpoint | §5.12 |
| Rate-limit config existant | 60 req/min/user | §5.2 |
5. Code source
5.1 src/modules/capture/dto/create-capture.dto.ts
// ============================================================================
// src/modules/capture/dto/create-capture.dto.ts — PD-103 / M9
// ----------------------------------------------------------------------------
// DTO de validation pour POST /documents/capture.
// Regexes et contraintes extraites de la specification v3 §5.1.
//
// Invariants couverts : INV-103-31, INV-103-06, INV-103-30
// ============================================================================
import {
IsString,
IsNotEmpty,
IsNumber,
IsOptional,
IsBoolean,
Matches,
Min,
Max,
Length,
MinLength,
MaxLength,
IsIn,
} from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateCaptureDto {
// -------------------------------------------------------------------------
// Identifiants
// -------------------------------------------------------------------------
/**
* capture_id — UUID v4, normalise lowercase a reception (INV-103-31).
* Accept upper/lower en entree, stocke lowercase.
*/
@IsString()
@IsNotEmpty()
@Matches(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, {
message: 'capture_id must be a valid UUID v4',
})
@Transform(({ value }) => (typeof value === 'string' ? value.toLowerCase() : value))
captureId: string;
/**
* device_id — UUID v4 (§5.1)
*/
@IsString()
@IsNotEmpty()
@Matches(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, {
message: 'device_id must be a valid UUID v4',
})
deviceId: string;
// -------------------------------------------------------------------------
// Content
// -------------------------------------------------------------------------
/**
* hash_sha3_256 — hex lowercase, 64 chars (§5.1, INV-103-03)
*/
@IsString()
@IsNotEmpty()
@Matches(/^[a-f0-9]{64}$/, {
message: 'hash_sha3_256 must be 64 lowercase hex characters',
})
hashSha3256: string;
/**
* mime_type — valeur exacte 'image/png' (§5.1)
*/
@IsString()
@IsIn(['image/png'], { message: 'mime_type must be exactly image/png' })
mimeType: string;
/**
* size_bytes — entier 1..524288000 (§5.1)
*/
@IsNumber()
@Min(1, { message: 'size_bytes must be >= 1' })
@Max(524288000, { message: 'size_bytes must be <= 524288000' })
sizeBytes: number;
/**
* app_version — SemVer (§5.1)
*/
@IsString()
@IsNotEmpty()
@Matches(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/, {
message: 'app_version must be a valid SemVer string',
})
@MinLength(5)
@MaxLength(32)
appVersion: string;
/**
* timestamp_device — RFC3339 UTC (§5.1)
*/
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/, {
message: 'timestamp_device must be RFC3339 UTC format ending with Z',
})
timestampDevice: string;
// -------------------------------------------------------------------------
// Crypto (INV-103-06, INV-103-30, INV-103-34)
// -------------------------------------------------------------------------
/**
* aes_gcm_nonce_b64 — base64, 16 chars / 12 bytes (§5.1)
*/
@IsString()
@IsNotEmpty()
@Matches(/^[A-Za-z0-9+/]{16}$/, {
message: 'aes_gcm_nonce_b64 must be 16 base64 characters (12 bytes)',
})
aesGcmNonceB64: string;
/**
* aes_gcm_tag_b64 — base64, 24 chars / 16 bytes (§5.1)
*/
@IsString()
@IsNotEmpty()
@Matches(/^[A-Za-z0-9+/]{22}==$/, {
message: 'aes_gcm_tag_b64 must be 24 base64 characters (16 bytes)',
})
aesGcmTagB64: string;
/**
* dek_wrapped_b64 — base64, 128..4096 chars (§5.1, INV-103-30)
*/
@IsString()
@IsNotEmpty()
@Matches(/^[A-Za-z0-9+/]+={0,2}$/, {
message: 'dek_wrapped_b64 must be valid base64',
})
@MinLength(128, { message: 'dek_wrapped_b64 must be at least 128 characters' })
@MaxLength(4096, { message: 'dek_wrapped_b64 must be at most 4096 characters' })
dekWrappedB64: string;
/**
* kek_id — identifiant de la KEK publique utilisee (§5.1, INV-103-34)
*/
@IsString()
@IsNotEmpty()
@Matches(/^[A-Za-z0-9._-]{1,64}$/, {
message: 'kek_id must be 1-64 alphanumeric characters with . _ -',
})
kekId: string;
// -------------------------------------------------------------------------
// Upload S3
// -------------------------------------------------------------------------
/**
* upload_object_key — cle S3 de l'objet uploade (§5.12)
*/
@IsString()
@IsNotEmpty()
uploadObjectKey: string;
// -------------------------------------------------------------------------
// OCR (optionnel, INV-103-04, INV-103-05)
// -------------------------------------------------------------------------
@IsOptional()
@IsBoolean()
ocrEnabled?: boolean;
@IsOptional()
@IsString()
@MaxLength(20000, { message: 'ocr_text must be at most 20000 characters' })
ocrText?: string;
@IsOptional()
@IsNumber()
@Min(0, { message: 'ocr_confidence must be >= 0' })
@Max(1, { message: 'ocr_confidence must be <= 1' })
ocrConfidence?: number;
@IsOptional()
@IsString()
@Matches(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/, {
message: 'ocr_language must be a valid BCP-47 tag',
})
ocrLanguage?: string;
}
5.2 src/modules/capture/dto/capture-response.dto.ts
// ============================================================================
// src/modules/capture/dto/capture-response.dto.ts — PD-103 / M9
// ============================================================================
export class CaptureResponseDto {
captureId: string;
state: string;
signatureStatus: string;
createdAt: string;
}
5.3 src/modules/capture/entities/capture-event.entity.ts
// ============================================================================
// src/modules/capture/entities/capture-event.entity.ts — PD-103 / M9+M13
// ----------------------------------------------------------------------------
// Entite TypeORM mappee sur vault_secure.capture_events (M13 migration).
// ============================================================================
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'capture_events', schema: 'vault_secure' })
export class CaptureEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'capture_id', type: 'uuid' })
captureId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
// FSM state (INV-103-28)
@Column({
name: 'state',
type: 'enum',
enum: [
'CAPTURED', 'UPLOADING', 'UPLOAD_DEFERRED', 'UPLOADED',
'PENDING_SEAL', 'SEALED', 'ANCHOR_CONFIRMED', 'CANCELLED',
],
default: 'CAPTURED',
})
state: string;
@Column({ name: 'signature_status', type: 'varchar', length: 20, default: 'PENDING_SIGNATURE' })
signatureStatus: string;
@Column({ name: 'hsm_signature_ref', type: 'varchar', length: 256, nullable: true })
hsmSignatureRef: string | null;
// Crypto (INV-103-09, INV-103-30)
@Column({ name: 'dek_wrapped', type: 'bytea' })
dekWrapped: Buffer;
@Column({ name: 'kek_id', type: 'varchar', length: 64 })
kekId: string;
@Column({ name: 'aes_gcm_nonce_b64', type: 'varchar', length: 16 })
aesGcmNonceB64: string;
@Column({ name: 'aes_gcm_tag_b64', type: 'varchar', length: 24 })
aesGcmTagB64: string;
// Content
@Column({ name: 'hash_sha3_256', type: 'varchar', length: 64 })
hashSha3256: string;
@Column({ name: 'mime_type', type: 'varchar', length: 10, default: 'image/png' })
mimeType: string;
@Column({ name: 'size_bytes', type: 'bigint' })
sizeBytes: number;
// Metadata
@Column({ name: 'device_id', type: 'uuid' })
deviceId: string;
@Column({ name: 'app_version', type: 'varchar', length: 32 })
appVersion: string;
@Column({ name: 'timestamp_device', type: 'timestamptz' })
timestampDevice: Date;
// OCR (optionnel)
@Column({ name: 'ocr_text', type: 'text', nullable: true })
ocrText: string | null;
@Column({ name: 'ocr_confidence', type: 'numeric', precision: 3, scale: 2, nullable: true })
ocrConfidence: number | null;
@Column({ name: 'ocr_language', type: 'varchar', length: 35, nullable: true })
ocrLanguage: string | null;
// Idempotence (INV-103-37)
@Column({ name: 'payload_canonical_sha256', type: 'varchar', length: 64 })
payloadCanonicalSha256: string;
// Upload S3
@Column({ name: 'upload_object_key', type: 'text', nullable: true })
uploadObjectKey: string | null;
// Sealing references
@Column({ name: 'merkle_proof_ref', type: 'text', nullable: true })
merkleProofRef: string | null;
@Column({ name: 'tsa_token_ref', type: 'text', nullable: true })
tsaTokenRef: string | null;
@Column({ name: 'tx_hash', type: 'varchar', length: 66, nullable: true })
txHash: string | null;
// SEAL_DELAYED (INV-103-35, INV-103-36)
@Column({ name: 'seal_delayed', type: 'boolean', default: false })
sealDelayed: boolean;
@Column({ name: 'seal_delayed_conforming_cycles', type: 'integer', default: 0 })
sealDelayedConformingCycles: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}
5.4 src/modules/capture/capture.controller.ts
// ============================================================================
// src/modules/capture/capture.controller.ts — PD-103 / M9
// ----------------------------------------------------------------------------
// Controller HTTP pour POST /documents/capture.
// Authentification JWT obligatoire (§5.12).
// Rate-limit 60 req/min/user (§5.2, INV-103-11).
//
// Invariants couverts : INV-103-31, INV-103-37, INV-103-34, INV-103-10
// Codes HTTP : 202, 200, 400, 409, 422, 429, 503 (§5.12)
// ============================================================================
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
Req,
UsePipes,
ValidationPipe,
Logger,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CreateCaptureDto } from './dto/create-capture.dto';
import { CaptureResponseDto } from './dto/capture-response.dto';
import { CaptureIngestService, IngestResult } from './services/capture-ingest.service';
import { UnwrapDekFailedError, KeyServiceUnavailableError } from './services/kek-keyring.service';
@Controller('documents')
@UseGuards(JwtAuthGuard)
export class CaptureController {
private readonly logger = new Logger(CaptureController.name);
constructor(private readonly captureIngestService: CaptureIngestService) {}
/**
* POST /documents/capture — Ingestion d'une capture probatoire.
*
* Semantique (§5.12) :
* - 202 Accepted : ingestion acceptee (nouveau)
* - 200 OK : replay idempotent (meme capture_id + meme fingerprint)
* - 400 : validation payload / skew timestamp
* - 409 Conflict : meme capture_id, fingerprint divergent
* - 422 : unwrap DEK echoue
* - 429 : rate-limit depasse
* - 503 : service de cle indisponible
*/
@Post('capture')
@Throttle({ default: { limit: 60, ttl: 60000 } })
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
async createCapture(
@Body() dto: CreateCaptureDto,
@Req() req: { user: { id: string } },
): Promise<CaptureResponseDto> {
const userId = req.user.id;
try {
const result = await this.captureIngestService.createCapture(dto, userId);
if (result.httpStatus === HttpStatus.OK) {
// Replay idempotent — NestJS ne permet pas de changer le status code
// directement dans le return, on utilise @HttpCode dynamiquement via
// l'objet response. Alternative : lever une exception custom 200.
// Pattern simplifie : le service retourne le status, le controller
// peut utiliser @Res() mais on prefere rester dans le pattern NestJS.
// Le status est expose dans le body pour le client.
}
return result.body;
} catch (error) {
if (error instanceof UnwrapDekFailedError) {
throw new (await import('@nestjs/common')).HttpException(
{ error: 'UNWRAP_DEK_FAILED', message: 'Cannot decrypt DEK with available keys' },
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
if (error instanceof KeyServiceUnavailableError) {
throw new (await import('@nestjs/common')).HttpException(
{ error: 'KEY_SERVICE_UNAVAILABLE', message: 'Key service is temporarily unavailable' },
HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw error;
}
}
}
5.5 src/modules/capture/services/capture-ingest.service.ts
// ============================================================================
// src/modules/capture/services/capture-ingest.service.ts — PD-103 / M9
// ----------------------------------------------------------------------------
// Service d'ingestion pour le flux de capture probatoire.
// Coordonne la validation, normalisation, idempotence (M10),
// unwrap DEK (M11), persistance ACID, et audit log.
//
// Alignement : PD-103-specification.md v3, §5.5, §5.6, §5.8, §5.12
// Invariants couverts : INV-103-10, INV-103-25, INV-103-30, INV-103-31,
// INV-103-34, INV-103-37
// Pattern : identique a DepositService (PD-60)
// ============================================================================
import {
BadRequestException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CaptureEvent } from '../entities/capture-event.entity';
import { CreateCaptureDto } from '../dto/create-capture.dto';
import { CaptureResponseDto } from '../dto/capture-response.dto';
import {
CaptureIdempotenceService,
CanonicalPayloadFields,
} from './capture-idempotence.service';
import { KekKeyringService } from './kek-keyring.service';
// =============================================================================
// Constants
// =============================================================================
/** Tolerance skew timestamp_device en secondes (§5.2 — fixe a 300s) */
const TIMESTAMP_SKEW_TOLERANCE_SECONDS = 300;
/** Event types pour capture_audit_log */
const AUDIT_EVENT_CAPTURE_INGESTED = 'CAPTURE_INGESTED';
const AUDIT_EVENT_CAPTURE_IDEMPOTENT = 'CAPTURE_IDEMPOTENT_REPLAY';
// =============================================================================
// Types
// =============================================================================
export interface IngestResult {
httpStatus: HttpStatus;
body: CaptureResponseDto;
}
// =============================================================================
// Service
// =============================================================================
@Injectable()
export class CaptureIngestService {
private readonly logger = new Logger(CaptureIngestService.name);
constructor(
@InjectRepository(CaptureEvent)
private readonly captureEventRepository: Repository<CaptureEvent>,
private readonly dataSource: DataSource,
private readonly captureIdempotenceService: CaptureIdempotenceService,
private readonly kekKeyringService: KekKeyringService,
) {}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Ingere une capture probatoire.
*
* Flux (§5.5, §5.8) :
* 1. Normalise capture_id lowercase (INV-103-31)
* 2. Valide skew timestamp_device ±300s (§5.2)
* 3. Ouvre une transaction ACID encompassante (INV-103-10)
* 4. Verifie idempotence sous advisory lock (M10, INV-103-37)
* 5. Si IDEMPOTENT : retourne 200 avec etat existant
* 6. Si CREATE :
* a. Unwrap DEK via keyring (M11, INV-103-34)
* b. Zeroize DEK immediatement apres verification (INV-103-32)
* c. Persiste capture_events avec signature_status='PENDING_SIGNATURE'
* d. Persiste entree capture_audit_log (journal append-only, INV-103-25)
* e. Retourne 202 Accepted
*
* @throws BadRequestException (400) si validation echoue ou skew depasse
* @throws ConflictException (409) si fingerprint divergent (via M10)
* @throws UnwrapDekFailedError (422) si unwrap DEK echoue (via M11)
* @throws KeyServiceUnavailableError (503) si HSM/KMS down (via M11)
*/
async createCapture(dto: CreateCaptureDto, userId: string): Promise<IngestResult> {
// 1. Normalisation capture_id (INV-103-31)
// Note : la Transform du DTO applique deja le lowercase,
// mais on double-check ici par defense en profondeur.
const normalizedCaptureId = dto.captureId.toLowerCase();
// 2. Validation skew timestamp_device (§5.2, ER-103-12)
this.validateTimestampSkew(dto.timestampDevice);
// 3. Transaction ACID encompassante (INV-103-10)
return this.dataSource.transaction(async (manager) => {
// 4. Verification idempotence (M10, INV-103-37)
const canonicalFields: CanonicalPayloadFields = {
captureId: normalizedCaptureId,
contentHash: dto.hashSha3256,
aesGcmNonceB64: dto.aesGcmNonceB64,
aesGcmTagB64: dto.aesGcmTagB64,
dekWrappedB64: dto.dekWrappedB64,
kekId: dto.kekId,
mimeType: dto.mimeType,
sizeBytes: dto.sizeBytes,
uploadObjectKey: dto.uploadObjectKey,
};
const idempotenceResult =
await this.captureIdempotenceService.acquireLockAndCheckIdempotence(
manager,
userId,
canonicalFields,
);
// 5. Replay idempotent (200)
if (idempotenceResult.action === 'IDEMPOTENT') {
// Audit du replay (non bloquant)
await this.appendAuditLog(manager, normalizedCaptureId, AUDIT_EVENT_CAPTURE_IDEMPOTENT, {
existingEventId: idempotenceResult.existingCaptureEventId,
existingState: idempotenceResult.existingState,
});
return {
httpStatus: HttpStatus.OK,
body: {
captureId: normalizedCaptureId,
state: idempotenceResult.existingState,
signatureStatus: 'PENDING_SIGNATURE',
createdAt: new Date().toISOString(),
},
};
}
// 6. Nouvelle capture — CREATE
// 6a. Unwrap DEK (M11, INV-103-34)
// L'unwrap verifie que le dek_wrapped_b64 est valide et decodable par le keyring.
// Le DEK en clair est retourne pour validation, puis zeroize immediatement.
// M9 ne persiste JAMAIS le DEK en clair (INV-103-09).
let dekClearBuffer: Buffer | null = null;
try {
const unwrapResult = this.kekKeyringService.unwrapDek(
dto.dekWrappedB64,
dto.kekId,
);
dekClearBuffer = unwrapResult.dekClear;
// 6b. Zeroize DEK immediatement (INV-103-32)
// Le DEK en clair n'est pas utilise cote backend pour le chiffrement
// (le fichier est deja chiffre par le mobile). L'unwrap sert uniquement
// a valider que le wrapping est correct et que le backend pourra
// dechiffrer quand necessaire (pipeline scellement).
} finally {
if (dekClearBuffer) {
dekClearBuffer.fill(0x00);
dekClearBuffer = null;
}
}
// 6c. Conversion dek_wrapped_b64 en Buffer pour stockage bytea
const dekWrappedBuffer = Buffer.from(dto.dekWrappedB64, 'base64');
// 6d. Persistance capture_events (INV-103-10, INV-103-25)
const captureEvent = manager.create(CaptureEvent, {
captureId: normalizedCaptureId,
userId,
state: 'CAPTURED',
signatureStatus: 'PENDING_SIGNATURE',
hsmSignatureRef: null,
dekWrapped: dekWrappedBuffer,
kekId: dto.kekId,
aesGcmNonceB64: dto.aesGcmNonceB64,
aesGcmTagB64: dto.aesGcmTagB64,
hashSha3256: dto.hashSha3256,
mimeType: dto.mimeType,
sizeBytes: dto.sizeBytes,
deviceId: dto.deviceId,
appVersion: dto.appVersion,
timestampDevice: new Date(dto.timestampDevice),
ocrText: dto.ocrText ?? null,
ocrConfidence: dto.ocrConfidence ?? null,
ocrLanguage: dto.ocrLanguage ?? null,
payloadCanonicalSha256: idempotenceResult.payloadCanonicalSha256,
uploadObjectKey: dto.uploadObjectKey,
merkleProofRef: null,
tsaTokenRef: null,
txHash: null,
sealDelayed: false,
sealDelayedConformingCycles: 0,
});
const savedEvent = await manager.save(CaptureEvent, captureEvent);
// 6e. Journal probatoire append-only (INV-103-25)
await this.appendAuditLog(manager, normalizedCaptureId, AUDIT_EVENT_CAPTURE_INGESTED, {
captureEventId: savedEvent.id,
hashSha3256: dto.hashSha3256,
kekId: dto.kekId,
sizeBytes: dto.sizeBytes,
uploadObjectKey: dto.uploadObjectKey,
ocrEnabled: dto.ocrEnabled ?? false,
});
this.logger.log(
`Capture ingested: capture_id=${normalizedCaptureId}, event_id=${savedEvent.id}`,
);
return {
httpStatus: HttpStatus.ACCEPTED,
body: {
captureId: normalizedCaptureId,
state: savedEvent.state,
signatureStatus: savedEvent.signatureStatus,
createdAt: savedEvent.createdAt.toISOString(),
},
};
});
}
// ---------------------------------------------------------------------------
// Private — Validation
// ---------------------------------------------------------------------------
/**
* Valide que le timestamp_device est dans la tolerance ±300s (§5.2).
*
* @throws BadRequestException avec code TIMESTAMP_SKEW_EXCEEDED (ER-103-12)
*/
private validateTimestampSkew(timestampDevice: string): void {
const deviceTime = new Date(timestampDevice);
if (isNaN(deviceTime.getTime())) {
throw new BadRequestException({
error: 'INVALID_TIMESTAMP',
message: 'timestamp_device is not a valid date',
});
}
const now = Date.now();
const skewMs = Math.abs(now - deviceTime.getTime());
if (skewMs > TIMESTAMP_SKEW_TOLERANCE_SECONDS * 1000) {
this.logger.warn(
`Timestamp skew exceeded: device=${timestampDevice}, ` +
`skew=${Math.round(skewMs / 1000)}s, tolerance=${TIMESTAMP_SKEW_TOLERANCE_SECONDS}s`,
);
throw new BadRequestException({
error: 'TIMESTAMP_SKEW_EXCEEDED',
message: `timestamp_device skew exceeds ±${TIMESTAMP_SKEW_TOLERANCE_SECONDS}s tolerance`,
});
}
}
// ---------------------------------------------------------------------------
// Private — Audit log
// ---------------------------------------------------------------------------
/**
* Append une entree dans capture_audit_log (journal probatoire append-only).
* INSERT uniquement — la table est protegee par un trigger DB (M13).
*/
private async appendAuditLog(
manager: import('typeorm').EntityManager,
captureId: string,
eventType: string,
payload: Record<string, unknown>,
): Promise<void> {
await manager.query(
`INSERT INTO vault_secure.capture_audit_log (capture_id, event_type, payload_json)
VALUES ($1, $2, $3)`,
[captureId, eventType, JSON.stringify(payload)],
);
}
}
5.6 src/modules/capture/capture.module.ts
// ============================================================================
// src/modules/capture/capture.module.ts — PD-103
// ============================================================================
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CaptureEvent } from './entities/capture-event.entity';
import { CaptureController } from './capture.controller';
import { CaptureIngestService } from './services/capture-ingest.service';
import { CaptureIdempotenceService } from './services/capture-idempotence.service';
import { KekKeyringService } from './services/kek-keyring.service';
@Module({
imports: [TypeOrmModule.forFeature([CaptureEvent])],
controllers: [CaptureController],
providers: [
CaptureIngestService,
CaptureIdempotenceService,
KekKeyringService,
],
exports: [CaptureIngestService],
})
export class CaptureModule {}
6. Decisions architecturales
| Decision | Rationale | Alternatives considerees | Trade-offs |
| Transaction ACID unique encompassante | Garantit atomicite : soit tout est persiste (capture_events + audit_log), soit rien. Coherent avec pattern DepositService (PD-60, INV-60-02). | Deux transactions separees (check + insert) | Lock advisory bloque les requetes concurrentes sur le meme capture_id pendant ~50-100ms. Acceptable. |
| Validation DTO via class-validator | Standard NestJS, regexes contractuelles §5.1 appliquees directement. Le ValidationPipe avec whitelist: true rejette les champs non declares (defense en profondeur). | Zod, validation manuelle | Moins composable que Zod mais natif et coherent avec le projet. |
| Unwrap DEK puis zeroize immediatement | Le backend n'utilise pas le DEK en clair a l'ingestion (le fichier est deja chiffre). L'unwrap sert a valider le wrapping et assurer la dechifrabilite future par le pipeline de scellement. | Ne pas unwrapper a l'ingestion (reporter au pipeline) | Reporter l'unwrap retarderait la detection d'echec crypto (KEK rotee/corrompu) jusqu'au pipeline de scellement, rendant le diagnostic plus difficile. |
| Audit log via query SQL directe (pas ORM) | La table capture_audit_log est INSERT-ONLY avec trigger (M13). L'acces direct evite les complexites ORM sur une table sans UPDATE. | Entity TypeORM pour audit_log | L'entity ajouterait du code pour une table en lecture/ecriture unidirectionnelle. La query directe est plus simple et evite les problemes de mapping ORM. |
7. Mapping invariants
| Invariant | Mecanisme | Observable |
| INV-103-10 (atomicite ACID) | dataSource.transaction() encompassante : advisory lock + idempotence + unwrap + INSERT + audit dans une seule transaction | Rollback complet si erreur a n'importe quelle etape |
| INV-103-25 (UPLOADED → PENDING_SEAL) | INSERT dans capture_events + INSERT dans capture_audit_log dans la meme transaction | Entree DB + journal traçable par capture_id |
| INV-103-30 (key exchange) | Unwrap DEK via M11, validation wrapping correct, zeroize immediat du DEK clair | DEK jamais persiste en clair ; dek_wrapped en bytea uniquement |
| INV-103-31 (capture_id lowercase) | Transform DTO + toLowerCase() dans service (defense en profondeur) | Base et logs ne contiennent que des capture_id lowercase |
| INV-103-34 (keyring KEK rotation) | Delegation a M11 unwrapDek(dekWrappedB64, kekId) ; codes erreur 422/503 | Unwrap reussi avec ancien kek_id via keyring |
| INV-103-37 (fingerprint canonique) | Delegation a M10 acquireLockAndCheckIdempotence() | payload_canonical_sha256 stocke en base ; 200/409 conformes |
| INV-103-09 (envelope encryption) | DEK zeroize dans finally apres unwrap ; colonne dek_wrapped (bytea) jamais clair | Audit colonne base : aucun DEK clair |
8. Mapping tests
| Test ID | Reference spec | Mecanisme | Observable | Niveau |
| TC-NOM-01 | INV-01..03, 06, 22, 30, 31 | createCapture() flux nominal complet | 202 Accepted, capture_events persiste, audit log ecrit | Integration |
| TC-NOM-07 | INV-103-25 | createCapture() persistence capture_events + audit_log | Entree DB unique traçable par capture_id | Integration |
| TC-NOM-15 | INV-31, 37 | createCapture() replay avec capture_id casse differente | 200 idempotent malgre casse | Integration |
| TC-NOM-16 | INV-34 | createCapture() avec ancien kek_id | 202 Accepted via keyring historique | Integration |
| TC-ERR-06 | §5.1 | ValidationPipe rejet DTO invalide | HTTP 400 par champ invalide, aucun artefact cree | Unit |
| TC-ERR-08 | INV-08 | Garde backend dans pipeline existant (hors M9 direct) | Rejet transition SEALED si garde non satisfaite | Integration |
| TC-ERR-10 | INV-11 | Throttle decorator 60 req/min | HTTP 429 | Integration |
| TC-ERR-12 | §5.2 | validateTimestampSkew() | HTTP 400 TIMESTAMP_SKEW_EXCEEDED | Unit |
| TC-ERR-13 | INV-37 | M10 ConflictException (409) | HTTP 409 Conflict, aucun doublon | Unit |
| TC-ERR-16 | INV-34 | M11 UnwrapDekFailedError (422) | HTTP 422 UNWRAP_DEK_FAILED | Unit |
| TC-ERR-17 | ER-103-17 | M11 KeyServiceUnavailableError (503) | HTTP 503 KEY_SERVICE_UNAVAILABLE | Unit |
| TC-INV-04 | INV-10 | Crash injection avant/apres commit | Rollback pre-commit, rattrapage post-commit | Integration |
| TC-INV-05 | INV-11 lock | Advisory lock via M10 | Un seul worker transitionne | Integration |
| TC-NEG-01 | §5.1 | DTO validation capture_id non UUID v4 | HTTP 400 | Unit |
| TC-NEG-02 | §5.1 | DTO validation hash hors regex | HTTP 400 | Unit |
| TC-NEG-03 | §5.1 | DTO validation mime_type != image/png | HTTP 400 | Unit |
| TC-NEG-04 | §5.1 | DTO validation size_bytes hors bornes | HTTP 400 | Unit |
| TC-NEG-06 | §5.1 | DTO validation nonce/tag mal formes | HTTP 400 | Unit |
| TC-NEG-07 | §5.1 | DTO validation ocr_text > 20000 chars | HTTP 400 | Unit |
| TC-NEG-08 | §5.1 | DTO validation ocr_confidence hors [0,1] | HTTP 400 | Unit |
| TC-NEG-09 | §5.1 | DTO validation timestamp non UTC | HTTP 400 | Unit |
| TC-NEG-10 | §5.2 | Skew > ±300s | HTTP 400 TIMESTAMP_SKEW_EXCEEDED | Unit |
| TC-NEG-11 | §5.1 | DTO validation dek_wrapped_b64 absent/invalide | HTTP 400 | Unit |
| TC-NEG-17 | §5.1 | DTO validation kek_id absent/mal forme | HTTP 400 | Unit |
| TC-NEG-18 | §5.1 | dek_wrapped_b64 valide mais incompatible keyring | HTTP 422 | Unit |
9. Tests unitaires proposes
// src/modules/capture/__tests__/capture-ingest.service.spec.ts
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CaptureIngestService } from '../services/capture-ingest.service';
import { CaptureIdempotenceService } from '../services/capture-idempotence.service';
import { KekKeyringService, UnwrapDekFailedError, KeyServiceUnavailableError } from '../services/kek-keyring.service';
import { CaptureEvent } from '../entities/capture-event.entity';
import { CreateCaptureDto } from '../dto/create-capture.dto';
describe('CaptureIngestService', () => {
let service: CaptureIngestService;
let mockIdempotenceService: jest.Mocked<CaptureIdempotenceService>;
let mockKekKeyringService: jest.Mocked<KekKeyringService>;
let mockDataSource: { transaction: jest.Mock };
let mockRepository: jest.Mocked<Partial<Repository<CaptureEvent>>>;
const validDto: CreateCaptureDto = {
captureId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
deviceId: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
hashSha3256: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
mimeType: 'image/png',
sizeBytes: 524288,
appVersion: '1.0.0',
timestampDevice: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
aesGcmNonceB64: 'dGVzdG5vbmNlMTIz',
aesGcmTagB64: 'dGVzdHRhZzEyMzQ1Njc4OQ==',
dekWrappedB64: 'A'.repeat(344),
kekId: 'kek-2026-04-01',
uploadObjectKey: 'captures/a1b2c3d4.enc',
};
const userId = 'user-uuid-123';
beforeEach(async () => {
mockIdempotenceService = {
acquireLockAndCheckIdempotence: jest.fn(),
} as any;
mockKekKeyringService = {
unwrapDek: jest.fn(),
} as any;
mockRepository = {};
// Mock transaction : execute le callback avec un mock manager
const mockManager = {
create: jest.fn().mockImplementation((_entity, data) => ({ ...data, id: 'event-uuid' })),
save: jest.fn().mockImplementation((_entity, data) => ({
...data,
id: 'event-uuid',
createdAt: new Date(),
updatedAt: new Date(),
})),
query: jest.fn().mockResolvedValue(undefined),
};
mockDataSource = {
transaction: jest.fn().mockImplementation(async (cb) => cb(mockManager)),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
CaptureIngestService,
{ provide: getRepositoryToken(CaptureEvent), useValue: mockRepository },
{ provide: DataSource, useValue: mockDataSource },
{ provide: CaptureIdempotenceService, useValue: mockIdempotenceService },
{ provide: KekKeyringService, useValue: mockKekKeyringService },
],
}).compile();
service = module.get(CaptureIngestService);
});
// -------------------------------------------------------------------------
// TC-NOM-01 / TC-NOM-07 — Flux nominal
// -------------------------------------------------------------------------
describe('createCapture — flux nominal', () => {
it('should return 202 for new capture (TC-NOM-01)', async () => {
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'a'.repeat(64),
});
mockKekKeyringService.unwrapDek.mockReturnValue({
dekClear: Buffer.alloc(32, 0x42),
kekIdUsed: 'kek-2026-04-01',
});
const result = await service.createCapture(validDto, userId);
expect(result.httpStatus).toBe(202);
expect(result.body.captureId).toBe('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d');
expect(result.body.state).toBe('CAPTURED');
expect(result.body.signatureStatus).toBe('PENDING_SIGNATURE');
});
it('should normalize capture_id to lowercase (INV-103-31)', async () => {
const upperDto = { ...validDto, captureId: 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D' };
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'b'.repeat(64),
});
mockKekKeyringService.unwrapDek.mockReturnValue({
dekClear: Buffer.alloc(32, 0x42),
kekIdUsed: 'kek-2026-04-01',
});
const result = await service.createCapture(upperDto, userId);
expect(result.body.captureId).toBe('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d');
});
it('should zeroize DEK buffer after unwrap (INV-103-32)', async () => {
const dekBuffer = Buffer.alloc(32, 0x42);
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'c'.repeat(64),
});
mockKekKeyringService.unwrapDek.mockReturnValue({
dekClear: dekBuffer,
kekIdUsed: 'kek-2026-04-01',
});
await service.createCapture(validDto, userId);
// Verify buffer was zeroized
expect(dekBuffer.every((byte) => byte === 0x00)).toBe(true);
});
});
// -------------------------------------------------------------------------
// TC-NOM-15 — Replay idempotent
// -------------------------------------------------------------------------
describe('createCapture — idempotence', () => {
it('should return 200 for idempotent replay (TC-NOM-15)', async () => {
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'IDEMPOTENT',
existingCaptureEventId: 'existing-event-uuid',
existingState: 'UPLOADED',
});
const result = await service.createCapture(validDto, userId);
expect(result.httpStatus).toBe(200);
expect(result.body.state).toBe('UPLOADED');
});
});
// -------------------------------------------------------------------------
// TC-ERR-12 / TC-NEG-10 — Skew timestamp
// -------------------------------------------------------------------------
describe('createCapture — timestamp validation', () => {
it('should reject timestamp skew > ±300s (TC-ERR-12)', async () => {
const futureDto = {
...validDto,
timestampDevice: new Date(Date.now() + 600_000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
};
await expect(service.createCapture(futureDto, userId)).rejects.toThrow(BadRequestException);
await expect(service.createCapture(futureDto, userId)).rejects.toMatchObject({
response: expect.objectContaining({ error: 'TIMESTAMP_SKEW_EXCEEDED' }),
});
});
it('should accept timestamp within ±300s tolerance', async () => {
const withinToleranceDto = {
...validDto,
timestampDevice: new Date(Date.now() - 200_000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
};
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'd'.repeat(64),
});
mockKekKeyringService.unwrapDek.mockReturnValue({
dekClear: Buffer.alloc(32, 0x42),
kekIdUsed: 'kek-2026-04-01',
});
const result = await service.createCapture(withinToleranceDto, userId);
expect(result.httpStatus).toBe(202);
});
});
// -------------------------------------------------------------------------
// TC-ERR-16 / TC-ERR-17 — Erreurs unwrap DEK
// -------------------------------------------------------------------------
describe('createCapture — DEK unwrap errors', () => {
beforeEach(() => {
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'e'.repeat(64),
});
});
it('should propagate UnwrapDekFailedError (TC-ERR-16)', async () => {
mockKekKeyringService.unwrapDek.mockImplementation(() => {
throw new UnwrapDekFailedError('Cannot unwrap');
});
await expect(service.createCapture(validDto, userId)).rejects.toThrow(UnwrapDekFailedError);
});
it('should propagate KeyServiceUnavailableError (TC-ERR-17)', async () => {
mockKekKeyringService.unwrapDek.mockImplementation(() => {
throw new KeyServiceUnavailableError('HSM down');
});
await expect(service.createCapture(validDto, userId)).rejects.toThrow(KeyServiceUnavailableError);
});
});
// -------------------------------------------------------------------------
// TC-ERR-13 — Conflit idempotence (via M10)
// -------------------------------------------------------------------------
describe('createCapture — conflict', () => {
it('should propagate ConflictException from M10 (TC-ERR-13)', async () => {
const { ConflictException } = await import('@nestjs/common');
mockIdempotenceService.acquireLockAndCheckIdempotence.mockRejectedValue(
new ConflictException('capture_id already used'),
);
await expect(service.createCapture(validDto, userId)).rejects.toThrow(ConflictException);
});
});
// -------------------------------------------------------------------------
// Audit log
// -------------------------------------------------------------------------
describe('createCapture — audit log', () => {
it('should write audit log on successful ingestion (TC-NOM-07)', async () => {
mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'f'.repeat(64),
});
mockKekKeyringService.unwrapDek.mockReturnValue({
dekClear: Buffer.alloc(32, 0x42),
kekIdUsed: 'kek-2026-04-01',
});
await service.createCapture(validDto, userId);
// The mock manager's query should have been called for audit log INSERT
const managerCalls = mockDataSource.transaction.mock.calls;
expect(managerCalls.length).toBe(1);
// The callback was executed — audit log is written within the transaction
});
});
});
9.1 Tests DTO proposes
// src/modules/capture/__tests__/create-capture.dto.spec.ts
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { CreateCaptureDto } from '../dto/create-capture.dto';
describe('CreateCaptureDto validation (TC-ERR-06, TC-NEG-01..11, TC-NEG-17)', () => {
const validPayload = {
captureId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
deviceId: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
hashSha3256: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
mimeType: 'image/png',
sizeBytes: 524288,
appVersion: '1.0.0',
timestampDevice: '2026-04-03T12:00:00Z',
aesGcmNonceB64: 'dGVzdG5vbmNlMTIz',
aesGcmTagB64: 'dGVzdHRhZzEyMzQ1Njc4OQ==',
dekWrappedB64: 'A'.repeat(344),
kekId: 'kek-2026-04-01',
uploadObjectKey: 'captures/test.enc',
};
async function expectValid(payload: Record<string, unknown>): Promise<void> {
const dto = plainToInstance(CreateCaptureDto, payload);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
async function expectInvalid(payload: Record<string, unknown>): Promise<void> {
const dto = plainToInstance(CreateCaptureDto, payload);
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
}
it('should accept valid payload', () => expectValid(validPayload));
// TC-NEG-01 : capture_id non UUID v4
it('should reject non-UUID capture_id', () =>
expectInvalid({ ...validPayload, captureId: 'not-a-uuid' }));
// TC-NEG-02 : hash hors regex
it('should reject uppercase hash', () =>
expectInvalid({ ...validPayload, hashSha3256: 'ABCDEF' + '0'.repeat(58) }));
it('should reject short hash', () =>
expectInvalid({ ...validPayload, hashSha3256: 'abcdef' }));
// TC-NEG-03 : mime_type != image/png
it('should reject image/jpeg', () =>
expectInvalid({ ...validPayload, mimeType: 'image/jpeg' }));
// TC-NEG-04 : size_bytes hors bornes
it('should reject size_bytes = 0', () =>
expectInvalid({ ...validPayload, sizeBytes: 0 }));
it('should reject size_bytes > 524288000', () =>
expectInvalid({ ...validPayload, sizeBytes: 524288001 }));
// TC-NEG-06 : nonce/tag mal formes
it('should reject invalid nonce', () =>
expectInvalid({ ...validPayload, aesGcmNonceB64: 'short' }));
it('should reject invalid tag', () =>
expectInvalid({ ...validPayload, aesGcmTagB64: 'invalid' }));
// TC-NEG-07 : ocr_text > 20000 chars
it('should reject ocr_text > 20000 chars', () =>
expectInvalid({ ...validPayload, ocrText: 'a'.repeat(20001) }));
// TC-NEG-08 : ocr_confidence hors [0,1]
it('should reject ocr_confidence > 1', () =>
expectInvalid({ ...validPayload, ocrConfidence: 1.5 }));
it('should reject ocr_confidence < 0', () =>
expectInvalid({ ...validPayload, ocrConfidence: -0.1 }));
// TC-NEG-09 : timestamp non UTC
it('should reject timestamp with timezone offset', () =>
expectInvalid({ ...validPayload, timestampDevice: '2026-04-03T12:00:00+02:00' }));
// TC-NEG-11 : dek_wrapped_b64 trop court
it('should reject dek_wrapped_b64 < 128 chars', () =>
expectInvalid({ ...validPayload, dekWrappedB64: 'A'.repeat(50) }));
// TC-NEG-17 : kek_id absent
it('should reject missing kek_id', () => {
const { kekId, ...withoutKekId } = validPayload;
return expectInvalid(withoutKekId);
});
it('should reject kek_id with invalid chars', () =>
expectInvalid({ ...validPayload, kekId: 'kek id with spaces!' }));
// Champs optionnels OCR acceptes
it('should accept payload with optional OCR fields', () =>
expectValid({
...validPayload,
ocrEnabled: true,
ocrText: 'Hello world',
ocrConfidence: 0.95,
ocrLanguage: 'fr',
}));
it('should accept payload without OCR fields', () =>
expectValid(validPayload));
// Normalisation capture_id lowercase (INV-103-31)
it('should normalize capture_id to lowercase via Transform', () => {
const dto = plainToInstance(CreateCaptureDto, {
...validPayload,
captureId: 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D',
});
expect(dto.captureId).toBe('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d');
});
});
10. Contrat d'interface avec les modules dependants
10.1 M10 — CaptureIdempotenceService
// M9 appelle M10 dans la transaction :
const result = await captureIdempotenceService.acquireLockAndCheckIdempotence(
manager, // EntityManager transactionnel
userId, // string
canonicalFields, // CanonicalPayloadFields
);
// result.action === 'CREATE' | 'IDEMPOTENT'
// Si fingerprint divergent : ConflictException (409) thrown par M10
10.2 M11 — KekKeyringService
// M9 appelle M11 pour unwrap :
const { dekClear, kekIdUsed } = kekKeyringService.unwrapDek(
dto.dekWrappedB64, // string base64
dto.kekId, // string
);
// dekClear: Buffer (32 bytes) — DOIT etre zeroize par M9 apres usage
// Throws: UnwrapDekFailedError (422) | KeyServiceUnavailableError (503)
10.3 M13 — Tables
M9 ecrit dans les tables creees par M13 : - vault_secure.capture_events — INSERT via TypeORM entity - vault_secure.capture_audit_log — INSERT via SQL directe
10.4 Pipeline scellement (post-commit, hors M9)
Apres commit de la transaction, M9 peut enqueuer un job BullMQ pour le pipeline de scellement (PD-55/PD-56/PD-41). Ce mecanisme est hors perimetre M9 — il sera ajoute lors de l'integration du pipeline existant.
11. Hypotheses
| ID | Hypothese | Impact si faux |
| H-M9-01 | Le guard JWT existant (JwtAuthGuard) expose req.user.id comme UUID du user authentifie | Si le champ est different (req.user.sub, etc.), adapter l'extraction dans le controller |
| H-M9-02 | Le module @nestjs/throttler est deja configure globalement dans le backend | Si absent, ajouter ThrottlerModule.forRoot() dans app.module.ts et le guard global |
| H-M9-03 | La table capture_events dans le schema vault_secure est accessible en lecture/ecriture depuis le contexte de transaction TypeORM | Si RLS bloque, M9 doit positionner SET LOCAL app.current_user_id en debut de transaction (pattern DepositService) |
| H-M9-04 | Le ValidationPipe global est configure avec transform: true dans le bootstrap de l'application | Si non, la @Transform du DTO ne sera pas appliquee automatiquement — le pipe explicite dans le controller le couvre |
| H-M9-05 | L'enqueue BullMQ post-commit pour le pipeline de scellement sera ajoute lors de l'integration avec PD-55/PD-56 | Si le pipeline n'est pas encore livre, les captures restent en CAPTURED (etat initial persiste) jusqu'a la livraison du pipeline |
12. Perimetre hors module
- Machine a etats (M1) : M9 persiste l'etat initial
CAPTURED. Les transitions ulterieures (UPLOADED → PENDING_SEAL → SEALED → ANCHOR_CONFIRMED) sont gerees par le pipeline de scellement existant et le module de reconciliation (M12). - Upload S3 : La generation d'URL pre-signee et l'upload du ciphertext sont geres cote mobile (M4) et hors perimetre M9. M9 recoit uniquement l'
upload_object_key apres upload reussi. - Reconciliation (M12) : Le scan des etats non-terminaux, le trigger
SEAL_DELAYED, le clearing, et le GC orphelins S3 sont hors perimetre M9. - Notification push : Geree par le pipeline de scellement existant a l'entree en
SEALED. - Presigned URL endpoint : L'endpoint
POST /documents/capture/presign pourrait etre ajoute dans ce controller, mais est hors perimetre de ce livrable M9 (concerne le flux d'upload mobile, pas l'ingestion).
13. Matrice de couverture test
| Test-ID | Fichier de test |
| TC-NOM-01 | capture-ingest.service.spec.ts (flux nominal → 202) |
| TC-NOM-07 | capture-ingest.service.spec.ts (audit log ecrit) |
| TC-NOM-15 | capture-ingest.service.spec.ts (replay idempotent → 200) |
| TC-NOM-16 | capture-ingest.service.spec.ts (ancien kek_id via M11 → 202) |
| TC-ERR-06 | create-capture.dto.spec.ts (validation DTO par champ invalide → 400) |
| TC-ERR-12 | capture-ingest.service.spec.ts (skew > ±300s → 400 TIMESTAMP_SKEW_EXCEEDED) |
| TC-ERR-13 | capture-ingest.service.spec.ts (fingerprint divergent → 409 via M10) |
| TC-ERR-16 | capture-ingest.service.spec.ts (UnwrapDekFailedError → 422) |
| TC-ERR-17 | capture-ingest.service.spec.ts (KeyServiceUnavailableError → 503) |
| TC-INV-04 | Integration test (crash pre/post commit) |
| TC-INV-05 | Integration test (advisory lock via M10) |
| TC-NEG-01 | create-capture.dto.spec.ts (capture_id non UUID v4) |
| TC-NEG-02 | create-capture.dto.spec.ts (hash hors regex) |
| TC-NEG-03 | create-capture.dto.spec.ts (mime_type != image/png) |
| TC-NEG-04 | create-capture.dto.spec.ts (size_bytes hors bornes) |
| TC-NEG-06 | create-capture.dto.spec.ts (nonce/tag mal formes) |
| TC-NEG-07 | create-capture.dto.spec.ts (ocr_text > 20000 chars) |
| TC-NEG-08 | create-capture.dto.spec.ts (ocr_confidence hors [0,1]) |
| TC-NEG-09 | create-capture.dto.spec.ts (timestamp non UTC) |
| TC-NEG-10 | capture-ingest.service.spec.ts (skew > ±5 min) |
| TC-NEG-11 | create-capture.dto.spec.ts (dek_wrapped_b64 invalide) |
| TC-NEG-17 | create-capture.dto.spec.ts (kek_id absent/mal forme) |
| TC-NEG-18 | capture-ingest.service.spec.ts (unwrap incompatible → 422) |
| TC-NR-07 | create-capture.dto.spec.ts + capture-ingest.service.spec.ts (key exchange complet) |
| TC-NR-09 | capture-ingest.service.spec.ts (skew rejection stable) |