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├── 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 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.
titreo / gift-card — cartes cadeaux
Section intitulée « 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é.
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.
ConfigBackedGiftCardRegistry
Section intitulée « ConfigBackedGiftCardRegistry »get(type, merchantId) :
- type inconnu de la plateforme →
ValidationError; - aucun accès actif pour ce marchand →
GiftProviderNotConfigured(409 GIFT_PROVIDER_NOT_CONFIGURED) ; - 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.
TitreoTokenCache
Section intitulée « TitreoTokenCache »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
401invalide le jeton de ces accès, et seulement le leur.
TitreoCredentialVerifier
Section intitulée « TitreoCredentialVerifier »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) |
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 ; le jeton est gardé 18 min (tokenTtlMs) dans leTitreoTokenCache. Un401sur un appel invalide le jeton. - Opérations sur
/api/Card:debit= PUT,cancel/refund= DELETE (mêmes paramètrescarte/ticket/montant). - Solde :
checkBalance= POST sur/api/CardV2, qui renvoie en plus lecodeProduit— donc l’émetteur, par le catalogueTITREO_PRODUCT_LABELS. Repli sur/api/CardsiCardV2est 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 :
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.
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-* 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 |
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 |
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 (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 »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 — extraitconst 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 = noGiftProviderif (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.