Couche Infrastructure
A quoi sert cette couche
Section intitulée « A quoi sert cette couche »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"]
Carte du dossier
Section intitulée « Carte du dossier »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 UUIDdb — persistance Firebird
Section intitulée « db — persistance Firebird »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 | — |
Transaction et unité de travail
Section intitulée « Transaction et unité de travail »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 partageeawait 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.
Chiffrement au repos dans les repos
Section intitulée « Chiffrement au repos dans les repos »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>).rowToLegreçoit un callback de déchiffrement appelé seulement quand la colonne est non nulle. - Outbox repo :
encryptSensitiveFieldschiffre les cléscvv/token/cardNumberdu payload JSON (AADoutbox:<id>) à l’enqueue,decryptSensitiveFieldsles restaure à la lecture par le worker. - PSP config repo :
secret_key_encetwebhook_secret_encchiffrés (AADpsp|<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.
psp — adapters PSP et registry
Section intitulée « psp — adapters PSP et registry »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 |
Le registry config-backed
Section intitulée « Le registry config-backed »C’est la pièce qui rend le système réellement multi-marchand. ConfigBackedPspRegistry combine deux choses :
- une
Map<string, PspProviderFactory>(les types de PSP supportés, câblés au démarrage), - le
MerchantPspConfigRepository(les credentials chiffrés, par marchand).
// ConfigBackedPspRegistry.get — resolution dynamique par marchandasync 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.
Surface commune des adapters
Section intitulée « Surface commune des adapters »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', AdyenmanualCapture: true. L’autorisation réserve les fonds (statutHELD), 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.idempotencyKeyau PSP (idempotencyKeyStripe,referenceAdyen). Voir Idempotence. - 3DS / REQUIRES_ACTION : Stripe expose
next_action.redirect_to_url, Adyen unaction; les deux renvoientstatus: 'REQUIRES_ACTION'+actionUrl. verifyWebhook: Stripe viawebhooks.constructEvent(signatureStripe-Signature), Adyen viahmacValidatorsur leNotificationRequestItem. 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 versWEBHOOK_SIGNATURE_INVALID/WEBHOOK_REPLAY. Les événements sont normalisés enWebhookEvent(AUTH_SUCCEEDED,CAPTURE_SUCCEEDED,REFUND_SUCCEEDED,DISPUTED,IGNORED…).mapNativeError: traduit les exceptions natives (Stripe.errors.*, statut HTTP Adyen 5xx/429) vers le catalogueErrorCodedu domaine avec un drapeauretriable. C’est ce qui permet à l’orchestrateur de décider rejeu vs échec définitif sans connaître le PSP.
gift / titreo / gift-card — cartes cadeaux
Section intitulée « gift / titreo / gift-card — cartes cadeaux »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 |
InMemoryGiftCardRegistry → GiftCardRegistry |
titreo/titreo-gift-card-adapter.ts |
Adapter réel Titreo : auth JWT (cache 18 min), POST/PUT/DELETE sur /api/Card |
TitreoGiftCardAdapter → GiftCardProvider ; config TitreoConfig |
gift/mock-gift-card-adapter.ts |
Adapter dev/test : solde dérivé des 4 derniers chiffres, persisté en mémoire | MockGiftCardAdapter → GiftCardProvider |
TitreoGiftCardAdapter
Section intitulée « TitreoGiftCardAdapter »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). Un401sur un appel invalide le cache. - Opérations sur
/api/Card:checkBalance= POST,debit= PUT,cancel/refund= DELETE (mêmes paramètrescarte/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 :
parseAmountgère la virgule décimale et convertit en centimes. La réponse Titreo (result === 0= succès) est normalisée ; sinonfailure()reportecodeRejet/messageFR. Les erreurs HTTP deviennent desProviderErrorpuis des résultatsFAILED.
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).
crypto — chiffrement, hash, tokens, signature
Section intitulée « crypto — chiffrement, hash, tokens, signature »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 |
AEAD : la brique de chiffrement au repos
Section intitulée « AEAD : la brique de chiffrement au repos »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 contexteconst ct = Buffer.concat([cipher.update(pt, 'utf8'), cipher.final()])return `v1:${iv}:${ct}:${cipher.getAuthTag()}` // base64 par segmentSignature ed25519 des manifests
Section intitulée « Signature ed25519 des manifests »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.
webhooks — envoi sortant securise
Section intitulée « webhooks — envoi sortant securise »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 |
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).
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.
email / bootstrap / utilitaires
Section intitulée « email / bootstrap / utilitaires »| Fichier | Role | Classe / export |
|---|---|---|
email/console-email-sender.ts |
Phase 1 : écrit l’email sur stdout (à remplacer par SMTP/Resend/SES) | ConsoleEmailSender → EmailSender |
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 (renvoi)
Section intitulée « workers (renvoi) »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.
Comment tout est cable ensemble
Section intitulée « Comment tout est cable ensemble »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 infrastructureconst aead = new AesGcmAead(loadKekFromBase64(config.pspConfigKek)) // crypto/aead.tsconst pspConfigs = new FirebirdMerchantPspConfigRepo(db, aead) // db/...psp-config-repo.tsconst 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.