Aller au contenu

PD-103 — Agent Developer — Module capture-types

1. Identite agent

  • Agent : agent-developer (Agent B — Claude)
  • Story : PD-103
  • Module : capture-types
  • Projet : ProbatioVault-app (docs)

2. Mission

Definir l'ensemble des types TypeScript partages pour le module capture probatoire PD-103 : branded types, union litterale des etats, interfaces de donnees, constantes contractuelles, regex de validation, DTOs, et types d'erreur. Ce module est la fondation de type-safety pour tous les autres modules (M1..M8).

3. Fichier cible

src/capture/types.ts

4. Implementation

// ============================================================================
// src/capture/types.ts — PD-103
// ----------------------------------------------------------------------------
// Types TypeScript : etats, branded types, DTOs, constantes contractuelles
// pour la capture probatoire d'ecran et scellement automatique.
//
// Alignement : PD-103-specification.md v3, §3, §4, §5.1, §5.2, §5.7, §5.12
// ============================================================================

// =============================================================================
// Branded Types (INV contract : pas de string nu pour capture_id / device_id)
// Pattern existant : src/types/seal.ts, src/types/upload.ts
// =============================================================================

/** UUID v4 lowercase — identifiant unique de capture (§3, INV-103-31) */
export type CaptureId = string & { readonly __brand: "CaptureId" };

/** UUID v4 — identifiant du device (§5.1) */
export type DeviceId = string & { readonly __brand: "DeviceId" };

/** SHA3-256 hex lowercase, 64 chars (§5.1, INV-103-03) */
export type Sha3_256Hash = string & { readonly __brand: "Sha3_256Hash" };

/** AES-GCM nonce base64, 16 chars / 12 bytes (§5.1) */
export type AesGcmNonceB64 = string & { readonly __brand: "AesGcmNonceB64" };

/** AES-GCM auth tag base64, 24 chars / 16 bytes (§5.1) */
export type AesGcmTagB64 = string & { readonly __brand: "AesGcmTagB64" };

/** DEK wrappe RSA-OAEP-SHA256, base64 (§5.1, INV-103-30) */
export type DekWrappedB64 = string & { readonly __brand: "DekWrappedB64" };

/** Identifiant de la KEK publique utilisee au wrapping (§5.1, INV-103-34) */
export type KekId = string & { readonly __brand: "KekId" };

/** SemVer version applicative (§5.1) */
export type AppVersion = string & { readonly __brand: "AppVersion" };

/** RFC3339 UTC timestamp device (§5.1) */
export type TimestampDevice = string & { readonly __brand: "TimestampDevice" };

/** Texte OCR UTF-8 imprimable, 0..20000 chars (§5.1) */
export type OcrText = string & { readonly __brand: "OcrText" };

/** Tag BCP-47, 2..35 chars (§5.1) */
export type OcrLanguage = string & { readonly __brand: "OcrLanguage" };

/** Cle S3 de l'objet uploade (§5.12) */
export type UploadObjectKey = string & { readonly __brand: "UploadObjectKey" };

// =============================================================================
// Constructeurs branded types (pattern existant : asSealId, toDocId)
// =============================================================================

export function asCaptureId(value: string): CaptureId {
  return value.toLowerCase() as CaptureId; // INV-103-31 : normalisation lowercase
}

export function asDeviceId(value: string): DeviceId {
  return value as DeviceId;
}

export function asSha3_256Hash(value: string): Sha3_256Hash {
  return value as Sha3_256Hash;
}

export function asAesGcmNonceB64(value: string): AesGcmNonceB64 {
  return value as AesGcmNonceB64;
}

export function asAesGcmTagB64(value: string): AesGcmTagB64 {
  return value as AesGcmTagB64;
}

export function asDekWrappedB64(value: string): DekWrappedB64 {
  return value as DekWrappedB64;
}

export function asKekId(value: string): KekId {
  return value as KekId;
}

export function asAppVersion(value: string): AppVersion {
  return value as AppVersion;
}

export function asTimestampDevice(value: string): TimestampDevice {
  return value as TimestampDevice;
}

export function asOcrText(value: string): OcrText {
  return value as OcrText;
}

export function asOcrLanguage(value: string): OcrLanguage {
  return value as OcrLanguage;
}

export function asUploadObjectKey(value: string): UploadObjectKey {
  return value as UploadObjectKey;
}

// =============================================================================
// CaptureState — Union litterale exhaustive des 8 etats PD-103 (§5.7)
// =============================================================================

export const CAPTURE_STATES = [
  "CAPTURED",
  "UPLOADING",
  "UPLOAD_DEFERRED",
  "UPLOADED",
  "PENDING_SEAL",
  "SEALED",
  "ANCHOR_CONFIRMED",
  "CANCELLED",
] as const;

export type CaptureState = (typeof CAPTURE_STATES)[number];

export const TERMINAL_CAPTURE_STATES: readonly CaptureState[] = [
  "ANCHOR_CONFIRMED",
  "CANCELLED",
] as const;

// =============================================================================
// SignatureStatus — Enum backend (§5.1, INV-103-08)
// =============================================================================

export const SIGNATURE_STATUSES = ["PENDING_SIGNATURE", "SIGNED"] as const;

export type SignatureStatus = (typeof SIGNATURE_STATUSES)[number];

// =============================================================================
// Transitions autorisees — Table exhaustive (§5.7)
// =============================================================================

export const ALLOWED_TRANSITIONS: Readonly<Record<CaptureState, readonly CaptureState[]>> = {
  CAPTURED: ["UPLOADING", "CANCELLED"],
  UPLOADING: ["UPLOADED", "UPLOAD_DEFERRED", "CANCELLED"],
  UPLOAD_DEFERRED: ["UPLOADING", "CANCELLED"],
  UPLOADED: ["PENDING_SEAL"],
  PENDING_SEAL: ["SEALED"],
  SEALED: ["ANCHOR_CONFIRMED"],
  ANCHOR_CONFIRMED: [],
  CANCELLED: [],
} as const;

// =============================================================================
// Validation patterns — Regex contractuelles (§5.1)
// =============================================================================

export const CAPTURE_VALIDATION_PATTERNS = {
  /** UUID v4 (accepte upper/lower, normalise ensuite en lowercase) */
  UUID_V4: /^[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}$/,
  /** SHA3-256 hex lowercase, 64 chars */
  HASH_SHA3_256: /^[a-f0-9]{64}$/,
  /** RFC3339 UTC strict (§5.1) */
  TIMESTAMP_DEVICE: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/,
  /** SemVer (§5.1) */
  APP_VERSION: /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/,
  /** AES-GCM nonce base64 — 16 chars / 12 bytes */
  AES_GCM_NONCE_B64: /^[A-Za-z0-9+/]{16}$/,
  /** AES-GCM auth tag base64 — 24 chars / 16 bytes */
  AES_GCM_TAG_B64: /^[A-Za-z0-9+/]{22}==$/,
  /** DEK wrappe base64 — 128..4096 chars */
  DEK_WRAPPED_B64: /^[A-Za-z0-9+/]+={0,2}$/,
  /** KEK ID opaque — 1..64 chars alphanum + ._- */
  KEK_ID: /^[A-Za-z0-9._-]{1,64}$/,
  /** BCP-47 language tag */
  OCR_LANGUAGE: /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/,
} as const;

// =============================================================================
// Fonctions de validation (pattern existant : isValidDocId, isValidSha3Hash)
// =============================================================================

export function isValidCaptureId(id: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.UUID_V4.test(id);
}

export function isValidHashSha3_256(hash: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.HASH_SHA3_256.test(hash);
}

export function isValidTimestampDevice(ts: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.TIMESTAMP_DEVICE.test(ts);
}

export function isValidAppVersion(version: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.APP_VERSION.test(version);
}

export function isValidAesGcmNonceB64(nonce: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.AES_GCM_NONCE_B64.test(nonce);
}

export function isValidAesGcmTagB64(tag: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.AES_GCM_TAG_B64.test(tag);
}

export function isValidDekWrappedB64(dek: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.DEK_WRAPPED_B64.test(dek) && dek.length >= 128 && dek.length <= 4096;
}

export function isValidKekId(kekId: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.KEK_ID.test(kekId);
}

export function isValidOcrLanguage(lang: string): boolean {
  return CAPTURE_VALIDATION_PATTERNS.OCR_LANGUAGE.test(lang);
}

export function isTerminalCaptureState(state: CaptureState): boolean {
  return (TERMINAL_CAPTURE_STATES as readonly string[]).includes(state);
}

// =============================================================================
// Constantes contractuelles (§5.2, §5.3)
// =============================================================================

/** 500 MB en bytes — taille max image (§5.2) */
export const CAPTURE_SIZE_LIMIT = 524_288_000;

/** 10 MB en bytes — seuil multipart fixe produit (§5.2) */
export const MULTIPART_THRESHOLD = 10_000_000;

/** 5 MB en bytes — taille chunk multipart par defaut (§5.2) */
export const DEFAULT_CHUNK_SIZE = 5_242_880;

/** Bornes chunk size (§5.2) */
export const MIN_CHUNK_SIZE = 5_242_880;
export const MAX_CHUNK_SIZE = 52_428_800;

/** Max retries upload auto — defaut (§5.2) */
export const DEFAULT_UPLOAD_RETRIES = 3;
export const MIN_UPLOAD_RETRIES = 0;
export const MAX_UPLOAD_RETRIES = 5;

/** Backoff base retry en ms (§5.2) */
export const DEFAULT_BACKOFF_BASE_MS = 1_000;
export const MIN_BACKOFF_BASE_MS = 1_000;
export const MAX_BACKOFF_BASE_MS = 10_000;

/** TTL URL pre-signee S3 en secondes (§5.2) */
export const DEFAULT_PRESIGNED_URL_TTL_S = 900;
export const MIN_PRESIGNED_URL_TTL_S = 60;
export const MAX_PRESIGNED_URL_TTL_S = 900;

/** TTL session upload differe en secondes — defaut 24h (§5.2, §5.3) */
export const DEFAULT_DEFERRED_UPLOAD_TTL_S = 86_400;
export const MIN_DEFERRED_UPLOAD_TTL_S = 3_600;
export const MAX_DEFERRED_UPLOAD_TTL_S = 86_400;

/** Tolerance skew timestamp_device en secondes — fixe +-300s (§5.2) */
export const TIMESTAMP_SKEW_TOLERANCE_S = 300;

/** Rate-limit POST /documents/capture — defaut (§5.2) */
export const DEFAULT_RATE_LIMIT_PER_MIN = 60;

/** TTL objet S3 orphelin en secondes — defaut (§5.2) */
export const DEFAULT_ORPHAN_TTL_S = 900;
export const MIN_ORPHAN_TTL_S = 300;
export const MAX_ORPHAN_TTL_S = 3_600;

/** Profondeur keyring KEK — anciennes cles (§5.2) */
export const DEFAULT_KEK_KEYRING_DEPTH = 3;

/** Cycles conformes consecutifs pour clearing SEAL_DELAYED (§5.9, INV-103-36) */
export const SEAL_DELAYED_CLEARING_CYCLES = 3;

/** Latence capture P95 max en ms (§5.2, CA-103-01) */
export const CAPTURE_LATENCY_SLA_MS = 3_000;

/** Latence capture->upload P95 max en ms (§5.2, CA-103-08) */
export const CAPTURE_UPLOAD_SLA_MS = 5_000;

/** Seal SLA defaut en ms — 10 min (§5.3) */
export const SEAL_SLA_MS = 600_000;

/** Longueur max ocr_text (§5.1) */
export const OCR_TEXT_MAX_LENGTH = 20_000;

/** MIME type contractuel capture (§5.1) */
export const CAPTURE_MIME_TYPE = "image/png" as const;

/** Lock distribue TTL defaut en secondes (§5.9) */
export const DEFAULT_LOCK_TTL_S = 300;

/** Lock retry delay en secondes (§5.9) */
export const LOCK_RETRY_DELAY_S = 30;

// =============================================================================
// Metadonnees capture — Interface locale (§5.4)
// =============================================================================

export interface CaptureMetadata {
  readonly captureId: CaptureId;
  readonly deviceId: DeviceId;
  readonly appVersion: AppVersion;
  readonly mimeType: typeof CAPTURE_MIME_TYPE;
  readonly sizeBytes: number;
  readonly timestampDevice: TimestampDevice;
}

// =============================================================================
// OCR result — Interface locale optionnelle (§5.4, INV-103-04)
// =============================================================================

export interface OcrResult {
  readonly ocrText: OcrText;
  readonly ocrConfidence: number; // [0.00, 1.00]
  readonly ocrLanguage: OcrLanguage;
}

// =============================================================================
// Crypto artifacts — Resultats du pipeline crypto local (§5.4)
// =============================================================================

export interface CaptureEncryptionResult {
  readonly ciphertext: Uint8Array;
  readonly aesGcmNonceB64: AesGcmNonceB64;
  readonly aesGcmTagB64: AesGcmTagB64;
  readonly dekWrappedB64: DekWrappedB64;
  readonly kekId: KekId;
}

// =============================================================================
// POST /documents/capture — DTO requete (§5.12)
// =============================================================================

export interface CaptureIngestPayload {
  readonly capture_id: string; // CaptureId, serialise lowercase
  readonly hash_sha3_256: string; // Sha3_256Hash
  readonly timestamp_device: string; // TimestampDevice RFC3339 UTC
  readonly device_id: string; // DeviceId
  readonly app_version: string; // AppVersion SemVer
  readonly mime_type: typeof CAPTURE_MIME_TYPE;
  readonly size_bytes: number;
  readonly aes_gcm_nonce_b64: string; // AesGcmNonceB64
  readonly aes_gcm_tag_b64: string; // AesGcmTagB64
  readonly dek_wrapped_b64: string; // DekWrappedB64
  readonly kek_id: string; // KekId
  readonly upload_object_key: string; // UploadObjectKey — reference S3
  readonly ocr_text?: string; // OcrText — optionnel
  readonly ocr_confidence?: number; // [0.00, 1.00] — optionnel
  readonly ocr_language?: string; // OcrLanguage BCP-47 — optionnel
}

// =============================================================================
// POST /documents/capture — DTO reponse (§5.12)
// =============================================================================

export interface CaptureIngestResponse {
  readonly capture_id: string;
  readonly status: CaptureState;
  readonly signature_status: SignatureStatus;
  readonly created_at: string;
}

// =============================================================================
// Presigned URL — DTO (§5.5, §5.5bis)
// =============================================================================

export interface PresignedUrlResponse {
  readonly url: string;
  readonly object_key: string;
  readonly expires_at: string; // ISO 8601 UTC
}

export interface MultipartInitResponse {
  readonly upload_id: string;
  readonly parts: readonly PresignedPartUrl[];
  readonly object_key: string;
}

export interface PresignedPartUrl {
  readonly part_number: number;
  readonly url: string;
}

export interface CompletedPart {
  readonly partNumber: number;
  readonly etag: string;
}

// =============================================================================
// Upload differe — Payload local chiffre (§5.5, M5/M7)
// =============================================================================

export interface DeferredCapturePayload {
  readonly captureId: CaptureId;
  readonly ciphertextPath: string; // chemin local fichier chiffre
  readonly metadata: CaptureMetadata;
  readonly hashSha3_256: Sha3_256Hash;
  readonly aesGcmNonceB64: AesGcmNonceB64;
  readonly aesGcmTagB64: AesGcmTagB64;
  readonly dekWrappedB64: DekWrappedB64;
  readonly kekId: KekId;
  readonly ocr?: OcrResult;
  readonly deferredAt: string; // ISO 8601 UTC
  readonly multipartState?: MultipartResumeState;
}

export interface MultipartResumeState {
  readonly uploadId: string;
  readonly objectKey: string;
  readonly completedParts: readonly CompletedPart[];
  readonly totalParts: number;
  readonly chunkSize: number;
}

// =============================================================================
// Erreurs capture — Codes metier (§6, §5.12)
// =============================================================================

export const CAPTURE_ERROR_CODES = {
  TIMESTAMP_SKEW_EXCEEDED: "TIMESTAMP_SKEW_EXCEEDED",
  UNWRAP_DEK_FAILED: "UNWRAP_DEK_FAILED",
  KEY_SERVICE_UNAVAILABLE: "KEY_SERVICE_UNAVAILABLE",
  HASH_MISMATCH: "HASH_MISMATCH",
  ENCRYPTION_FAILED: "ENCRYPTION_FAILED",
  UPLOAD_FAILED: "UPLOAD_FAILED",
  UPLOAD_SESSION_EXPIRED: "UPLOAD_SESSION_EXPIRED",
  INVALID_TRANSITION: "INVALID_TRANSITION",
  TERMINAL_STATE: "TERMINAL_STATE",
  CHECKSUM_MISMATCH: "CHECKSUM_MISMATCH",
  OCR_FAILED: "OCR_FAILED",
  PURGE_FAILED: "PURGE_FAILED",
  PRESIGNED_URL_EXPIRED: "PRESIGNED_URL_EXPIRED",
} as const;

export type CaptureErrorCode = (typeof CAPTURE_ERROR_CODES)[keyof typeof CAPTURE_ERROR_CODES];

export interface CaptureError {
  readonly code: CaptureErrorCode;
  readonly message: string;
  readonly httpStatus?: number;
}

// =============================================================================
// HTTP error mapping (§6)
// =============================================================================

export const CAPTURE_HTTP_ERROR_KEYS: Readonly<Record<number, string>> = {
  400: "capture.error.badRequest",
  401: "capture.error.unauthorized",
  403: "capture.error.forbidden",
  409: "capture.error.conflict",
  422: "capture.error.unwrapFailed",
  429: "capture.error.rateLimited",
  503: "capture.error.serviceUnavailable",
} as const;

// =============================================================================
// Transition log — Historique des transitions (observabilite §8)
// =============================================================================

export interface CaptureTransitionLog {
  readonly captureId: CaptureId;
  readonly from: CaptureState;
  readonly to: CaptureState;
  readonly timestamp: string; // ISO 8601 UTC
  readonly reason?: string;
}

// =============================================================================
// Payload canonique d'idempotence — Cles triees alphabetiquement (§5.12)
// =============================================================================

export interface CanonicalPayload {
  readonly aes_gcm_nonce_b64: string;
  readonly aes_gcm_tag_b64: string;
  readonly capture_id: string; // lowercase
  readonly content_hash: string; // lowercase hash_sha3_256
  readonly dek_wrapped_b64: string;
  readonly kek_id: string;
  readonly mime_type: typeof CAPTURE_MIME_TYPE;
  readonly size_bytes: number;
  readonly upload_object_key: string;
}

// =============================================================================
// Upload progress (M4, M8 — indicateur progression UI)
// =============================================================================

export interface CaptureUploadProgress {
  readonly percent: number; // 0..100
  readonly bytesUploaded: number;
  readonly bytesTotal: number;
  readonly currentPart?: number; // multipart only
  readonly totalParts?: number; // multipart only
}

// =============================================================================
// Purge result (M7 — purgeStale + purge locale post-UPLOADED)
// =============================================================================

export interface CapturePurgeResult {
  readonly filesDeleted: number;
  readonly paths: readonly string[];
  readonly captureId?: CaptureId;
}

// =============================================================================
// Orchestrator result (M6 — resultat du flux complet)
// =============================================================================

export type CaptureOrchestratorResult =
  | { readonly success: true; readonly captureId: CaptureId; readonly finalState: "UPLOADED" }
  | { readonly success: false; readonly captureId: CaptureId; readonly finalState: "UPLOAD_DEFERRED" | "CANCELLED"; readonly error?: CaptureError };

5. Decisions architecturales

Decision 1 : Pattern branded types + constructeurs nommes

  • Decision : Reprendre le pattern existant seal.ts / upload.ts (branded types + fonctions asFoo() / toFoo()) plutot que des classes wrapper.
  • Rationale : Coherence avec le codebase existant (SealId, DocId, Sha3Hash). Zero overhead runtime, protection compile-time uniquement.
  • Alternatives : Classes wrapper avec validation au constructeur (rejete : overhead memoire mobile, casse l'interoperabilite JSON).
  • Trade-offs : Pas de validation runtime dans le constructeur (la validation est faite separement via isValidXxx()), mais compatible avec le pattern etabli.

Decision 2 : asCaptureId() normalise en lowercase

  • Decision : Le constructeur asCaptureId() applique .toLowerCase() automatiquement (INV-103-31).
  • Rationale : Garantit l'invariant de normalisation au point d'entree le plus bas. Impossible d'oublier la normalisation en aval.
  • Alternatives : Normaliser uniquement dans l'orchestrateur (rejete : fragile, oubli possible dans d'autres modules).
  • Trade-offs : Le constructeur a un effet de bord (mutation de casse). Acceptable car c'est contractuel et documentee.

6. Hypotheses

  • Les regex de validation §5.1 sont prises telles quelles de la specification v3. Toute modification de la spec entrainera une mise a jour de ce fichier.
  • CanonicalPayload est defini avec les cles en ordre alphabetique, conforme au contrat §5.12. Le calcul du fingerprint SHA-256 est dans le module M10 backend, pas dans ce fichier de types.
  • CaptureIngestPayload utilise des string (pas des branded types) car c'est le DTO wire-format envoye au backend. Les branded types sont utilises en amont dans le code mobile.

7. Matrice de couverture invariants

Invariant Couvert par Comment
INV-103-01-fidelity CaptureOrchestratorResult Type-safety du resultat final
INV-103-03-hash-local-first Sha3_256Hash branded type Distinction compile-time
INV-103-06-encryption-file-level CaptureEncryptionResult Structure typee du resultat crypto
INV-103-09-envelope-encryption DekWrappedB64, KekId Branded types pour artefacts crypto
INV-103-20..29 transitions ALLOWED_TRANSITIONS table Table exhaustive des transitions autorisees
INV-103-28-terminal-states TERMINAL_CAPTURE_STATES, isTerminalCaptureState() Detection programmatique
INV-103-30-key-exchange DekWrappedB64, KekId, CaptureEncryptionResult Types dedies wrapping
INV-103-31-capture-id-normalization asCaptureId() avec .toLowerCase() Normalisation au constructeur
INV-103-34-kek-keyring-rotation KekId branded type Tracabilite compile-time
INV-103-37-idempotency CanonicalPayload Structure normative des cles triees
INV-103-38-transition-deferred-cancelled DEFAULT_DEFERRED_UPLOAD_TTL_S Constante TTL contractuelle
INV-103-39-nonce-csprng AesGcmNonceB64 Type dedie pour le nonce

8. Tests contractuels couverts

Test ID Element type concerne
TC-NOM-01 CaptureIngestPayload (dek_wrapped_b64 + kek_id presents)
TC-NOM-12 isTerminalCaptureState(), TERMINAL_CAPTURE_STATES
TC-NOM-15 asCaptureId() normalisation lowercase
TC-ERR-06 CAPTURE_VALIDATION_PATTERNS (chaque regex)
TC-INV-02 ALLOWED_TRANSITIONS["CAPTURED"]
TC-INV-06 CanonicalPayload (cles triees, OCR exclu)
TC-INV-10 ALLOWED_TRANSITIONS["CANCELLED"], ALLOWED_TRANSITIONS["ANCHOR_CONFIRMED"]
TC-NEG-01..18 CAPTURE_VALIDATION_PATTERNS, CAPTURE_ERROR_CODES

9. Fichiers non modifies (hors perimetre)

Aucun fichier existant n'est modifie. Ce module cree un nouveau fichier src/capture/types.ts qui sera importe par les modules M1..M8.