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
├── gift/ adapter gift card mock (dev)
├── titreo/ adapter gift card Titreo (reel)
├── gift-card/ registry des providers gift card
├── 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) FirebirdUnitOfWorkUnitOfWork
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 FirebirdSessionRepoSessionRepository
db/firebird-outbox-repo.ts File outbox. enqueue, listReady, claim (UPDATE … RETURNING = verrou atomique), markCompleted, markFailed. Chiffre les champs sensibles du payload FirebirdOutboxRepoOutboxRepository
db/firebird-merchant-psp-config-repo.ts Config PSP par marchand, secrets chiffrés AEAD. findActive (déchiffre), findByProvider / listForMerchant (masqué ***), upsert, delete FirebirdMerchantPspConfigRepoMerchantPspConfigRepository
db/firebird-merchant-repo.ts Marchands (branding, gift provider, clés webhook) FirebirdMerchantRepoMerchantRepository
db/firebird-api-key-repo.ts Clés API marchand (hash stocké) FirebirdApiKeyRepoApiKeyRepository
db/firebird-idempotency-store.ts Store d’idempotence des requêtes entrantes FirebirdIdempotencyStoreIdempotencyStore
db/firebird-audit-log-repo.ts Journal d’audit FirebirdAuditLogRepoAuditLogRepository
db/firebird-admin-user-repo.ts Comptes admin plateforme FirebirdAdminUserRepoAdminUserRepository
db/firebird-merchant-user-repo.ts Utilisateurs console marchand FirebirdMerchantUserRepoMerchantUserRepository
db/firebird-merchant-user-invite-repo.ts Invitations utilisateur marchand FirebirdMerchantUserInviteRepoMerchantUserInviteRepository
db/firebird-user-session-repo.ts Sessions de connexion (cookie) FirebirdUserSessionRepoUserSessionRepository
db/firebird-webhook-events-repo.ts Dédup des événements PSP entrants (anti-rejeu) FirebirdWebhookEventsRepoWebhookEventsRepository
db/firebird-webhook-deliveries-repo.ts Suivi des livraisons webhook sortantes FirebirdWebhookDeliveriesRepoWebhookDeliveriesRepository
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 StripeAdapterPaymentProvider ; 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 AdyenAdapterPaymentProvider ; 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é.

Fichier Role Classe / export
gift-card/in-memory-gift-card-registry.ts Registre des providers gift card par type InMemoryGiftCardRegistryGiftCardRegistry
titreo/titreo-gift-card-adapter.ts Adapter réel Titreo : auth JWT (cache 18 min), POST/PUT/DELETE sur /api/Card TitreoGiftCardAdapterGiftCardProvider ; config TitreoConfig
gift/mock-gift-card-adapter.ts Adapter dev/test : solde dérivé des 4 derniers chiffres, persisté en mémoire MockGiftCardAdapterGiftCardProvider

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 ; l’adapter le met en cache et le rafraîchit à 18 min (tokenTtlMs). Un 401 sur un appel invalide le cache.
  • Opérations sur /api/Card : checkBalance = POST, debit = PUT, cancel / refund = DELETE (mêmes paramètres carte / ticket / montant).
  • 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.

Le MockGiftCardAdapter partage exactement la même interface (type = 'titreo') : on bascule réel↔mock au composition root via la config, sans toucher à l’orchestrateur (recette de bascule dans la mémoire project_epay_local_run).

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) AesGcmAeadAead ; 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 ScryptPasswordHasherPasswordHasher
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 CryptoUserSessionTokenServiceUserSessionTokenService ; generateUserSessionToken
crypto/ed25519-signer.ts Signe les manifests bootstrap en ed25519 (ed25519:<base64>) Ed25519SignerManifestSigner ; 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-*, refus des redirections HttpWebhookSenderWebhookSender
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).

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.

Fichier Role Classe / export
email/console-email-sender.ts Phase 1 : écrit l’email sur stdout (à remplacer par SMTP/Resend/SES) ConsoleEmailSenderEmailSender
bootstrap/in-memory-client-adapter-registry.ts Résout SDK + adapters PSP/gift pour le manifest bootstrap, seedé par env InMemoryClientAdapterRegistryClientAdapterRegistry ; SEED_DEFAULT_REGISTRY
clock.ts Horloge système (injectable pour tests déterministes) SystemClockClock
id-generator.ts Génération d’identifiants UUID v4 UuidGeneratorIdGenerator

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.

Le composition root api/src/http/server.ts est le seul endroit qui instancie ces classes et lit les variables d’environnement. Extrait commenté du câblage PSP réel :

// api/src/http/server.ts — assemblage des implementations infrastructure
const aead = new AesGcmAead(loadKekFromBase64(config.pspConfigKek)) // crypto/aead.ts
const pspConfigs = new FirebirdMerchantPspConfigRepo(db, aead) // db/...psp-config-repo.ts
const factories = new Map<string, PspProviderFactory>([
[STRIPE_PROVIDER_TYPE, stripeProviderFactory], // psp/stripe-factory.ts
[ADYEN_PROVIDER_TYPE, adyenProviderFactory], // psp/adyen-factory.ts
])
const pspRegistry = new ConfigBackedPspRegistry(factories, pspConfigs) // psp/in-memory-psp-registry.ts
const giftCardRegistry = new InMemoryGiftCardRegistry() // gift-card/...
if (titreoEnabled) giftCardRegistry.register(new TitreoGiftCardAdapter(config.titreo, clock))
else giftCardRegistry.register(new MockGiftCardAdapter())

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.