Aller au contenu

Couche Infrastructure

L’Architecture hexagonale divise le code en deux mondes. Au centre, le Domain et l’Application ne connaissent que des ports : des interfaces TypeScript abstraites (PaymentProvider, SessionRepository, Aead, WebhookSender…). Ils ne savent pas qu’il existe une base Firebird, ni que Stripe parle HTTP, ni comment on chiffre une clé secrète.

La couche Infrastructure (api/src/infrastructure/) fournit les implementations concretes de ces ports : le code qui sait vraiment ouvrir une connexion Firebird, appeler l’API REST de Stripe, dériver un hash scrypt ou poster un webhook signé. C’est la seule couche autorisée à importer des dépendances externes (stripe, @adyen/api-library, node-firebird-driver-native, undici, node:crypto).

flowchart TB
  subgraph APP["application (ports = interfaces)"]
    P1["PaymentProvider"]
    P2["SessionRepository / OutboxRepository"]
    P3["Aead / ManifestSigner"]
    P4["WebhookSender / EmailSender"]
  end
  subgraph INFRA["infrastructure (implementations)"]
    I1["StripeAdapter / AdyenAdapter"]
    I2["FirebirdSessionRepo / FirebirdOutboxRepo"]
    I3["AesGcmAead / Ed25519Signer"]
    I4["HttpWebhookSender / ConsoleEmailSender"]
  end
  P1 -.implemente par.-> I1
  P2 -.implemente par.-> I2
  P3 -.implemente par.-> I3
  P4 -.implemente par.-> I4
  I1 --> EXT1["API Stripe / Adyen"]
  I2 --> EXT2["Firebird"]
api/src/infrastructure/
├── db/ persistance Firebird (repos, UoW, serializers, migrations)
├── psp/ adapters PSP Stripe/Adyen + registry config-backed + validators
├── titreo/ adapter gift card Titreo, cache de jetons, verificateur d'acces
├── gift-card/ registry des providers gift card, par marchand
├── composition/ buildRegistries() : registres PSP et gift card, assembles une fois
├── observability/ logger, contexte ambiant, journal d'appels, redaction
├── logs/ lecture des logs dans Loki
├── crypto/ AEAD, hash mot de passe, tokens, signature ed25519
├── webhooks/ envoi HTTP signe + garde anti-SSRF
├── email/ envoi email (console pour Phase 1)
├── bootstrap/ registre des adapters client (manifest loader)
├── workers/ poller outbox + scheduler cleanup
├── clock.ts horloge systeme
└── id-generator.ts generateur UUID

Pas d’ORM : node-firebird-driver-native + SQL paramétré direct (règle non négociable, Prisma ne supporte pas Firebird). Le pattern est constant : chaque repo reçoit un FirebirdDb, exécute via attachment.executeQuery / attachment.execute, et convertit les lignes brutes (fetchAsObject) en agrégats du domaine via les serializers.

Fichier Role Classe / port
db/firebird-db.ts Connexion + gestion de transaction. connect, createIfMissing, withTransaction (READ_COMMITTED, commit/rollback auto), dispose FirebirdDb (pas de port — utilitaire bas niveau)
db/firebird-uow.ts Unité de travail : ouvre une transaction et fournit sessions + outbox + webhookEvents partageant la même tx (atomicité session + outbox) FirebirdUnitOfWork → UnitOfWork
db/serializers.ts Conversion ligne SQL ↔ agrégat. rowToSession, rowToLeg (déchiffre le CVV via callback), rowToOutbox. Types SessionRow / LegRow / OutboxRow fonctions pures
db/firebird-session-repo.ts Sessions + legs. findById, findByReference, findByPspAuthId (scopé merchant), save (upsert session + legs), list (curseur), listExpiredActive FirebirdSessionRepo → SessionRepository
db/firebird-outbox-repo.ts File outbox. enqueue, listReady, claim (UPDATE … RETURNING = verrou atomique), markCompleted, markFailed. Chiffre les champs sensibles du payload FirebirdOutboxRepo → OutboxRepository
db/firebird-merchant-psp-config-repo.ts Config PSP par marchand, secrets chiffrés AEAD. findActive (déchiffre), findByProvider / listForMerchant (masqué ***), upsert, delete FirebirdMerchantPspConfigRepo → MerchantPspConfigRepository
db/firebird-merchant-repo.ts Marchands (branding, gift provider, clés webhook) FirebirdMerchantRepo → MerchantRepository
db/firebird-api-key-repo.ts Clés API marchand (hash stocké) FirebirdApiKeyRepo → ApiKeyRepository
db/firebird-idempotency-store.ts Store d’idempotence des requêtes entrantes FirebirdIdempotencyStore → IdempotencyStore
db/firebird-audit-log-repo.ts Journal d’audit FirebirdAuditLogRepo → AuditLogRepository
db/firebird-admin-user-repo.ts Comptes admin plateforme FirebirdAdminUserRepo → AdminUserRepository
db/firebird-merchant-user-repo.ts Utilisateurs console marchand FirebirdMerchantUserRepo → MerchantUserRepository
db/firebird-merchant-user-invite-repo.ts Invitations utilisateur marchand FirebirdMerchantUserInviteRepo → MerchantUserInviteRepository
db/firebird-user-session-repo.ts Sessions de connexion (cookie) FirebirdUserSessionRepo → UserSessionRepository
db/firebird-webhook-events-repo.ts Dédup des événements PSP entrants (anti-rejeu) FirebirdWebhookEventsRepo → WebhookEventsRepository
db/firebird-webhook-deliveries-repo.ts Suivi des livraisons webhook sortantes FirebirdWebhookDeliveriesRepo → WebhookDeliveriesRepository
db/migrate.ts, db/migrate-cli.ts, db/migrations/ Migrations SQL — voir Base de donnees —

FirebirdDb.withTransaction ouvre une transaction READ_COMMITTED, exécute le work, commit en cas de succès, rollback sur exception. Chaque repo, s’il ne reçoit pas de tx explicite, ouvre sa propre transaction (lectures simples). Mais quand l’orchestrateur doit écrire la session et empiler des entrées outbox dans le même commit (garantie at-least-once), il passe par FirebirdUnitOfWork.run qui injecte la même tx aux trois repos :

// FirebirdUnitOfWork.run — une seule transaction partagee
await this.db.withTransaction(async (tx) => {
const repos = {
sessions: new FirebirdSessionRepo(this.db, this.aead, tx),
outbox: new FirebirdOutboxRepo(this.db, this.aead, tx),
webhookEvents: new FirebirdWebhookEventsRepo(this.db, tx),
}
return work(repos) // session.save + outbox.enqueue commitent ensemble
})

C’est le socle technique de l’Outbox & orchestration : pas de session sauvegardée sans son intention de paiement, et inversement.

Deux repos chiffrent des données sensibles avant insertion, via le port Aead :

  • Session repo : le CVV d’une carte cadeau (gift_cvv_enc) est chiffré avec un AAD lié à la leg (leg:<legId>). rowToLeg reçoit un callback de déchiffrement appelé seulement quand la colonne est non nulle.
  • Outbox repo : encryptSensitiveFields chiffre les clés cvv / token / cardNumber du payload JSON (AAD outbox:<id>) à l’enqueue, decryptSensitiveFields les restaure à la lecture par le worker.
  • PSP config repo : secret_key_enc et webhook_secret_enc chiffrés (AAD psp|<merchantId>|<providerType>).

L’AAD (Additional Authenticated Data) lie chaque chiffré à son contexte : un secret Stripe du marchand A ne peut pas être déchiffré sous l’identité du marchand B. Voir Securite.

C’est le cœur multi-PSP. Tout passe par le port PaymentProvider (Contrats) qui expose authorize / capture / void / refund / confirmAction / verifyWebhook et mapNativeError, plus un objet capabilities. L’orchestrateur ne connaît que ce port : il ignore si derrière c’est Stripe ou Adyen.

Fichier Role Classe / export
psp/in-memory-psp-registry.ts Registry config-backed : résout (providerType, merchantId) → PaymentProvider en lisant la config chiffrée puis en appelant la factory ConfigBackedPspRegistry, InMemoryPspRegistry (tests) → PspRegistry ; type PspProviderFactory
psp/stripe-adapter.ts Adapter Stripe (paymentIntents capture manuelle, refunds, constructEvent). Mappe statuts → AuthorizeResult et erreurs natives → ErrorCode StripeAdapter → PaymentProvider ; STRIPE_CAPABILITIES
psp/stripe-factory.ts Fabrique un StripeAdapter depuis une MerchantPspConfig (secretKey, webhookSecret) stripeProviderFactory, STRIPE_PROVIDER_TYPE
psp/adyen-adapter.ts Adapter Adyen (@adyen/api-library, capture/cancel/refund par pspReference, hmacValidator). Capture/refund forcés EUR AdyenAdapter → PaymentProvider ; ADYEN_CAPABILITIES
psp/adyen-factory.ts Fabrique un AdyenAdapter ; mappe accountRef→merchantAccount, dérive TEST/LIVE du préfixe de clé adyenProviderFactory, ADYEN_PROVIDER_TYPE
psp/config-validators.ts Schémas Zod par PSP (format sk_/whsec_ Stripe, HMAC 64 hex Adyen) PSP_CONFIG_VALIDATORS, getPspValidator, listSupportedProviders

C’est la pièce qui rend le système réellement multi-marchand. ConfigBackedPspRegistry combine deux choses :

  1. une Map<string, PspProviderFactory> (les types de PSP supportés, câblés au démarrage),
  2. le MerchantPspConfigRepository (les credentials chiffrés, par marchand).
// ConfigBackedPspRegistry.get — resolution dynamique par marchand
async get(providerType, merchantId): Promise<PaymentProvider> {
const factory = this.factories.get(providerType)
if (!factory) throw new ValidationError(`Unknown PSP provider: ${providerType}`)
const cfg = await this.configs.findActive(merchantId, providerType) // dechiffre AEAD
if (!cfg) throw new ValidationError(`No active ${providerType} config for merchant ${merchantId}`)
return factory(cfg) // -> new StripeAdapter / new AdyenAdapter avec les bons secrets
}

À chaque appel, on relit la config active du marchand, on déchiffre les secrets (AAD lié au marchand) et on instancie un adapter frais. Ajouter un nouveau PSP = écrire un adapter + une factory + un schéma Zod, puis enregistrer la factory dans la Map au composition root. Aucun changement dans le domaine ou les use cases. Voir le concept Contrats.

Les deux adapters respectent le même contrat et normalisent tout vers les types neutres du domaine :

sequenceDiagram
  participant UC as "Use case (application)"
  participant REG as "ConfigBackedPspRegistry"
  participant AD as "StripeAdapter / AdyenAdapter"
  participant EXT as "API PSP"
  UC->>REG: get("stripe", merchantId)
  REG->>REG: findActive() + dechiffre secrets
  REG-->>UC: PaymentProvider (adapter instancie)
  UC->>AD: authorize({ amount, paymentMethod, idempotencyKey })
  AD->>EXT: paymentIntents.create(capture_method=manual)
  EXT-->>AD: PaymentIntent (status)
  AD->>AD: mapAuthorize() / mapNativeError() -> ErrorCode
  AD-->>UC: AuthorizeResult { status: "HELD" | "REQUIRES_ACTION" | "FAILED" }

Points saillants :

  • Capture manuelle : Stripe capture_method: 'manual', Adyen manualCapture: true. L’autorisation réserve les fonds (statut HELD), la capture survient plus tard — clé du modèle split-tender (on ne débite qu’après que tous les instruments ont réservé).
  • Idempotence : chaque appel propage params.idempotencyKey au PSP (idempotencyKey Stripe, reference Adyen). Voir Idempotence.
  • 3DS / REQUIRES_ACTION : Stripe expose next_action.redirect_to_url, Adyen un action ; les deux renvoient status: 'REQUIRES_ACTION' + actionUrl.
  • verifyWebhook : Stripe via webhooks.constructEvent (signature Stripe-Signature), Adyen via hmacValidator sur le NotificationRequestItem. Les deux retournent des messages d’erreur génériques côté externe (ne pas fuiter la fenêtre temporelle ou la longueur du secret) et mappent le code natif vers WEBHOOK_SIGNATURE_INVALID / WEBHOOK_REPLAY. Les événements sont normalisés en WebhookEvent (AUTH_SUCCEEDED, CAPTURE_SUCCEEDED, REFUND_SUCCEEDED, DISPUTED, IGNORED…).
  • mapNativeError : traduit les exceptions natives (Stripe.errors.*, statut HTTP Adyen 5xx/429) vers le catalogue ErrorCode du domaine avec un drapeau retriable. C’est ce qui permet à l’orchestrateur de décider rejeu vs échec définitif sans connaître le PSP.

Les cartes cadeaux passent par un port distinct, GiftCardProvider : checkBalance / debit / cancel / refund. Sémantique différente d’un PSP (pas d’autorisation à deux temps : on lit le solde puis on débite directement), donc port séparé et registry séparé.

Chaque marchand appelle Titréo avec ses accès : son nom d’enseigne et sa clé. Il n’existe pas d’accès « plateforme », et aucun simulateur dans le code de service.

Fichier Role Classe / export
gift-card/config-backed-gift-card-registry.ts Registre par marchand : relit les accès actifs du marchand à chaque appel et construit son fournisseur ConfigBackedGiftCardRegistry → GiftCardRegistry
gift-card/in-memory-gift-card-registry.ts Registre en mémoire, pour les tests : un fournisseur par type, le même pour tous les marchands InMemoryGiftCardRegistry → GiftCardRegistry
titreo/titreo-gift-card-adapter.ts Adapter Titréo : porte les accès d’un marchand, POST/PUT/DELETE sur /api/Card, solde sur /api/CardV2 TitreoGiftCardAdapter → GiftCardProvider ; config TitreoConfig
titreo/titreo-factory.ts Fabrique un adapter depuis la configuration d’un marchand et les valeurs communes (adresse, catalogue) titreoGiftFactory, TitreoDefaults, GiftProviderFactory, TITREO_PROVIDER_TYPE
titreo/titreo-token-cache.ts Cache de jetons partagé, indexé par l’empreinte des accès TitreoTokenCache
titreo/titreo-credential-verifier.ts Vérifie des accès en demandant un jeton, sans toucher à une carte TitreoCredentialVerifier → GiftCredentialVerifier
titreo/titreo-logged-fetch.ts fetch journalisé pour Titréo : une ligne http_out par requête, sans la clé ni le jeton titreoLoggedFetch, describeTitreoCall
db/firebird-merchant-gift-config-repo.ts Accès chiffrés en base, table merchant_gift_configs FirebirdMerchantGiftConfigRepo → MerchantGiftConfigRepository ; giftAad

Le MockGiftCardAdapter a quitté src/ : il vit dans api/tests/fixtures/mock-gift-card-adapter.ts et ne sert qu’aux tests. Il s’activait en silence dès qu’une variable Titréo manquait.

get(type, merchantId) :

  1. type inconnu de la plateforme → ValidationError ;
  2. aucun accès actif pour ce marchand → GiftProviderNotConfigured (409 GIFT_PROVIDER_NOT_CONFIGURED) ;
  3. sinon, la fabrique construit l’adapter avec les accès déchiffrés du marchand.

Il n’y a aucun fournisseur vers lequel retomber : un marchand sans accès n’appelle personne, et jamais avec les accès d’un tiers.

Dans le worker, ProcessOutboxEntry intercepte GiftProviderNotConfigured et rend un substitut qui échoue proprement (unconfiguredGiftProvider). L’opération suit alors le chemin d’échec habituel — leg en échec, journal, relances bornées — au lieu de faire tomber le worker.

L’adapter est reconstruit à chaque appel avec les accès du marchand. Sans cache partagé, chaque opération paierait un aller-retour AuthToken.

  • La clé du cache est l’empreinte SHA-256 de baseUrl|enseigne|apiKey. Deux marchands n’ont jamais le même jeton ; changer de clé invalide de fait l’ancien.
  • Les demandes concurrentes pour les mêmes accès partagent un seul appel.
  • Un 401 invalide le jeton de ces accès, et seulement le leur.

Appelé avant tout enregistrement d’accès (UpsertGiftConfig). Il construit un adapter jetable, avec son propre cache, et appelle ping() — une demande de jeton, la seule opération qui n’engage aucune carte. Délai : 8 s.

Issue Cause Erreur levée par le use case
REJECTED Titréo a répondu en 4xx GIFT_CREDENTIALS_REJECTED (422)
UNREACHABLE 5xx, réseau, délai dépassé GIFT_PROVIDER_UNREACHABLE (503)

Adapter le plus riche du dossier. Il parle à la gateway REST Titreo (voir mémoire reference_titreo_rest_api) :

  • Auth : POST /api/AuthToken ({ enseigne, key }) renvoie un JWT de 20 min ; le jeton est gardé 18 min (tokenTtlMs) dans le TitreoTokenCache. Un 401 sur un appel invalide le jeton.
  • Opérations sur /api/Card : debit = PUT, cancel / refund = DELETE (mêmes paramètres carte / ticket / montant).
  • Solde : checkBalance = POST sur /api/CardV2, qui renvoie en plus le codeProduit — donc l’émetteur, par le catalogue TITREO_PRODUCT_LABELS. Repli sur /api/Card si CardV2 est en panne (404 ou 5xx) ; l’émetteur est alors inconnu. Un refus métier (carte inconnue, CVV faux) n’est pas une panne : il n’est pas rejoué.
  • shortTicket : Titreo rejette les tickets > 32 caractères ; les clés d’idempotence longues sont compressées en hash SHA-1 tronqué déterministe (donc toujours idempotent pour la même clé).
  • Mapping montants : parseAmount gère la virgule décimale et convertit en centimes. La réponse Titreo (result === 0 = succès) est normalisée ; sinon failure() reporte codeRejet / messageFR. Les erreurs HTTP deviennent des ProviderError puis des résultats FAILED.

Toute la cryptographie applicative, bâtie sur node:crypto (aucune lib tierce). Voir Securite.

Fichier Role Classe / export
crypto/aead.ts Chiffrement authentifié AES-256-GCM avec AAD. Format token v1:iv:ct:tag (base64) AesGcmAead → Aead ; loadKekFromBase64, generateKekBase64
crypto/payload-crypto.ts Chiffre/déchiffre sélectivement les champs sensibles (cvv, token, cardNumber) d’un payload JSON encryptSensitiveFields, decryptSensitiveFields
crypto/api-key.ts Génère une clé API marchand (tep_live_ / tep_test_), renvoie token + hash SHA-256 + last4 generateApiKey, hashApiKey
crypto/scrypt-password-hasher.ts Hash mot de passe scrypt (N=2^17, OWASP 2024), verify timing-safe + garde anti-DoS sur N ScryptPasswordHasher → PasswordHasher
crypto/session-token.ts Token de session checkout signé HMAC-SHA256 (tepc_<body>.<sig>) avec expiration signSessionToken, verifySessionToken, isSessionToken
crypto/user-session-token.ts Token de session console (cookie HttpOnly opaque), hash SHA-256 stocké en DB CryptoUserSessionTokenService → UserSessionTokenService ; generateUserSessionToken
crypto/ed25519-signer.ts Signe les manifests bootstrap en ed25519 (ed25519:<base64>) Ed25519Signer → ManifestSigner ; generateEd25519KeyPairPem

AesGcmAead est la primitive sous tous les chiffrés DB. La clé (KEK, 32 octets) provient de process.env.PSP_CONFIG_KEK décodée par loadKekFromBase64. Chaque appel encrypt(plaintext, aad) génère un IV aléatoire (12 octets) et lie le chiffré à son AAD : déchiffrer avec un AAD différent échoue (tag GCM invalide). C’est ce qui isole cryptographiquement les secrets par marchand / par leg / par entrée outbox.

// AesGcmAead.encrypt — sortie auto-descriptive "v1:iv:ct:tag"
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', this.key, iv)
cipher.setAAD(toAadBuf(aad)) // lie le chiffre a son contexte
const ct = Buffer.concat([cipher.update(pt, 'utf8'), cipher.final()])
return `v1:${iv}:${ct}:${cipher.getAuthTag()}` // base64 par segment

Ed25519Signer signe la réponse de bootstrap (manifest des adapters client) pour que le loader navigateur vérifie l’origine. Il charge une clé PKCS8 PEM, vérifie que c’est bien une clé ed25519, et préfixe la signature par ed25519: (algo explicite dans le token). Voir la section email / bootstrap / utilitaires ci-dessous.

L’orchestrateur notifie le backend marchand des transitions de session via webhooks. Deux fichiers, deux responsabilités séparées :

Fichier Role Classe / export
webhooks/http-webhook-sender.ts POST webhook via undici (Agent dédié, timeouts), headers x-titreo-* et x-correlation-id, refus des redirections, une ligne webhook_out par envoi HttpWebhookSender → WebhookSender
webhooks/url-guard.ts Garde anti-SSRF : valide que l’URL cible est publique avant tout envoi assertPublicUrl, UrlGuardError

HttpWebhookSender.send appelle d’abord assertPublicUrl, puis poste le corps avec les en-têtes de signature (x-titreo-signature, x-titreo-event, x-titreo-timestamp) et redirect: 'manual' (un 3xx est refusé, pour empêcher un contournement de la garde via redirection vers une IP privée). Il ajoute x-correlation-id, l’identifiant de la chaîne qui a produit l’événement : le marchand peut le citer au support et le renvoyer dans ses propres appels.

assertPublicUrl est la défense SSRF : elle bloque http:// (hors dev), les hostnames sensibles (localhost, metadata.google.internal…), les suffixes .internal / .local, et résout le DNS à chaque appel pour rejeter toute IP en plage privée / loopback / link-local (dont les endpoints de métadonnées cloud 169.254.169.254, IPv4-mapped IPv6 inclus). Seule la plage unicast (publique) passe. Voir Securite.

observability / logs — journal d’appels et lecture des logs

Section intitulée « observability / logs — journal d’appels et lecture des logs »

Ce que ces deux dossiers écrivent et relisent est décrit sur Traçabilité et Logs techniques. Ici, seulement la carte.

Fichier Role Classe / export
observability/logger.ts Options pino partagées par l’API et les workers : base, mixin du contexte ambiant, chemins censurés, niveau en toutes lettres hors développement loggerOptions, REDACT_PATHS
observability/worker-logger.ts Logger d’un worker, branché sur le journal d’appels createWorkerLogger
observability/request-context.ts Contexte ambiant (AsyncLocalStorage) : correlationId, marchand, acteur, sessionId, legId runWithContext, enterContext, enrichContext, currentContext
observability/call-log.ts Journal d’appels : une ligne par appel logCall, setCallLogSink, CallRecord, summarize, stripQuery
observability/redact.ts Censure récursive, bornes de profondeur et de taille redactDeep, serializeEventData
observability/timed-providers.ts Décorateurs des fournisseurs et des registres : mesure + ligne provider_call timedPspRegistry, timedGiftCardRegistry
observability/logged-fetch.ts fetch qui écrit une ligne http_out, sans rien changer à l’appel loggedFetch, FetchDescriber
psp/stripe-call-log.ts Une ligne http_out par requête du SDK Stripe, avec l’identifiant req_… attachStripeCallLog
logs/loki-log-store.ts Lecture des logs dans Loki par son API HTTP, délai de 15 s LokiLogStore → LogStore
logs/logql-builder.ts Traduction des critères en LogQL : liste blanche de caractères pour les identifiants, échappement pour le texte libre buildLogQuery, buildHistogramQuery, normalizeLevel
logs/null-log-store.ts Aucun collecteur raccordé : chaque lecture lève LogsDisabled NullLogStore → LogStore
Fichier Role Classe / export
email/console-email-sender.ts Phase 1 : écrit l’email sur stdout (à remplacer par SMTP/Resend/SES) ConsoleEmailSender → EmailSender
email/logged-email-sender.ts Enveloppe l’envoi SMTP : une ligne email_out par remise au relais, destinataire masqué, corps jamais écrit LoggedEmailSender → EmailSender ; maskEmail
bootstrap/in-memory-client-adapter-registry.ts Résout SDK + adapters PSP/gift pour le manifest bootstrap, seedé par env InMemoryClientAdapterRegistry → ClientAdapterRegistry ; SEED_DEFAULT_REGISTRY
clock.ts Horloge système (injectable pour tests déterministes) SystemClock → Clock
id-generator.ts Génération d’identifiants UUID v4 UuidGenerator → IdGenerator

InMemoryClientAdapterRegistry est le pendant client du registry PSP : il ne sert pas à exécuter des paiements côté serveur, mais à dire au loader navigateur quels bundles charger (URL + SRI integrity + client_config comme la clé publique Stripe/Adyen) et avec quel SDK. SEED_DEFAULT_REGISTRY fournit les valeurs par défaut, surchargées en dev par des variables ADAPTER_*.

SystemClock et UuidGenerator paraissent triviaux mais sont essentiels à l’hexagonal : le domaine n’appelle jamais new Date() ni randomUUID() directement, il reçoit Clock et IdGenerator en injection — d’où des tests reproductibles.

workers/outbox-poller.ts (classe OutboxPoller) et workers/cleanup-scheduler.ts (classe CleanupScheduler) vivent ici mais sont détaillés ailleurs : voir Workers. En résumé : le poller lit les entrées outbox prêtes (listReady / claim), exécute l’action PSP/gift correspondante via les adapters de cette page, et marque l’entrée COMPLETED ou FAILED avec backoff.

Les points d’entrée (api/src/http/server.ts, api/src/workers/outbox.ts, api/src/workers/cleanup.ts) instancient ces classes. Les registres des prestataires, eux, sont assemblés une seule fois, par buildRegistries : ils avaient divergé d’un point d’entrée à l’autre (le worker ne connaissait pas Adyen).

// api/src/infrastructure/composition/registries.ts — extrait
const pspConfigs = new FirebirdMerchantPspConfigRepo(input.db, input.aead)
const giftConfigs = new FirebirdMerchantGiftConfigRepo(input.db, input.aead)
const pspFactories = new Map<string, PspProviderFactory>([
[STRIPE_PROVIDER_TYPE, stripeProviderFactory],
[ADYEN_PROVIDER_TYPE, adyenProviderFactory],
])
const giftFactories = new Map<string, GiftProviderFactory>()
let giftCredentialVerifier: GiftCredentialVerifier = noGiftProvider
if (input.titreo) {
const titreoFetch = titreoLoggedFetch() // une ligne de journal par requête
giftFactories.set(
TITREO_PROVIDER_TYPE,
titreoGiftFactory(input.titreo, input.clock, new TitreoTokenCache(), titreoFetch),
)
giftCredentialVerifier = new TitreoCredentialVerifier(input.titreo, input.clock, titreoFetch)
}
return {
pspConfigs,
giftConfigs,
pspRegistry: timedPspRegistry(new ConfigBackedPspRegistry(pspFactories, pspConfigs)),
giftCardRegistry: timedGiftCardRegistry(
new ConfigBackedGiftCardRegistry(giftFactories, giftConfigs),
),
giftCredentialVerifier,
}

Sans config.titreo (TITREO_API_BASE absente), la table des fabriques reste vide et le vérificateur refuse tout : aucun marchand ne peut enregistrer d’accès.

Les registres sortent décorés (timedPspRegistry, timedGiftCardRegistry, observability/timed-providers.ts) : tout fournisseur qui en sort est chronométré et écrit une ligne provider_call par opération.

Ces pspRegistry / giftCardRegistry / aead sont ensuite passés aux use cases (Couche Application). Pour suivre une requête de bout en bout — du handler HTTP jusqu’à l’appel PSP — voir Parcours d appel.