Aller au contenu

Couche HTTP (Fastify)

La couche HTTP est la porte d’entrée réseau de l’API. Tout le reste du backend (use cases, domaine, infrastructure) est volontairement ignorant du protocole HTTP : il manipule des objets et des ports. Le rôle de api/src/http/ est de faire le pont entre le monde extérieur (requêtes JSON, en-têtes, cookies, tokens) et ces use cases.

Concrètement, cette couche fait trois choses, et rien d’autre :

  1. Parser et valider l’entrée (corps, query, params) avec Zod.
  2. Appeler le use case correspondant via le conteneur d’injection.
  3. Sérialiser la sortie (entité domaine → DTO JSON) et choisir le code HTTP.

Tout ce qui est transversal — authentification, autorisation par scope, idempotence, audit, gestion d’erreur, identifiant de corrélation — est délégué à des plugins Fastify branchés sur des hooks, pour ne jamais polluer les handlers.

flowchart LR
  C["Client (SDK / Console / PSP)"] --> P["Plugins (hooks: auth, idempotency, audit...)"]
  P --> H["Handler de route (parse Zod)"]
  H --> U["Use case (application/)"]
  U --> D["Domaine + Infrastructure"]
  H --> S["Sérialiseur (entité -> DTO)"]
  S --> C

Le point d’entrée du processus est api/src/http/server.ts. Sa fonction main() fait, dans l’ordre :

  1. Charge la config (loadConfig() depuis config/) — c’est le seul endroit qui lit process.env.
  2. Vérifie la présence de PSP_CONFIG_KEK (clé de chiffrement des secrets PSP), sinon échec immédiat.
  3. Construit le logger Pino avec une liste de redactPaths (PAN, CVV, tokens, secrets, en-têtes authorization/cookie/stripe-signature/x-titreo-signature censurés [REDACTED]).
  4. Ouvre la connexion Firebird (FirebirdDb.connect), instancie les adapters d’infrastructure (repos, AEAD, signer de manifeste…). Les registres PSP et cartes cadeaux viennent de buildRegistries (infrastructure/composition/registries.ts), la même fonction que pour les deux workers.
  5. Appelle buildContainer(...) (voir Couche Application) pour obtenir le conteneur d’injection.
  6. Appelle buildHttpApp(...) (dans app.ts) qui assemble l’instance Fastify.
  7. Branche SIGINT/SIGTERM sur un shutdown (ferme l’app puis la DB) et appelle app.listen({ port, host }).

buildHttpApp(opts) dans api/src/http/app.ts est la fabrique testable de l’application : elle reçoit le container déjà construit plus des options (clientTokenSecret, cookieSecret, nodeEnv, corsAllowedOrigins…) et retourne une FastifyInstance. La séparation server.ts (câblage des dépendances réelles) / app.ts (assemblage HTTP) permet aux tests d’intégration de construire l’app avec des ports mockés sans toucher au réseau.

sequenceDiagram
  participant Node as "Process Node"
  participant Srv as "server.ts main()"
  participant Cfg as "config/loadConfig"
  participant DB as "FirebirdDb"
  participant Cnt as "buildContainer"
  participant App as "app.ts buildHttpApp"
  Node->>Srv: démarrage
  Srv->>Cfg: loadConfig()
  Srv->>DB: connect(config.firebird)
  Srv->>Cnt: buildContainer(ports réels)
  Srv->>App: buildHttpApp({ container, options })
  App-->>Srv: FastifyInstance
  Srv->>App: app.listen({ port, host })

Dans buildHttpApp, l’ordre d’enregistrement est significatif (les hooks s’exécutent dans l’ordre d’inscription) :

  1. validatorCompiler / serializerCompiler de fastify-type-provider-zod → Zod devient le moteur de validation et de sérialisation des schémas de route.
  2. @fastify/helmet (en-têtes de sécurité, CSP désactivée ici).
  3. @fastify/rate-limit (global désactivé : global: false ; chaque route opte-in via config.rateLimit).
  4. Plugins maison : error-handler, request-context, call-log, metrics, puis @fastify/cors, @fastify/cookie, user-session, auth, idempotency, audit.
  5. En non-production uniquement : @fastify/swagger + Swagger UI sur /docs.
  6. Tous les modules de routes (healthRoutes, meRoutes(), sessionsRoutes(...), etc.).

Tous sont dans api/src/http/plugins/. Sauf require-scope et require-session-action (de simples fabriques de preHandler, pas des plugins fastify-plugin), ils sont enveloppés par fp(...) et déclarent leurs dépendances.

Fichier Hook(s) Rôle
plugins/request-context.ts onRequest Génère/propage un req.correlationId (depuis l’en-tête x-correlation-id s’il tient en 64 caractères, sinon un randomUUID), ouvre le contexte ambiant, renvoie l’identifiant dans la réponse.
plugins/call-log.ts preHandler + onResponse Pose sessionId dans le contexte ambiant sur les routes /v1/sessions/:id…, puis écrit une ligne http_in par requête : gabarit de route, statut, durée, code d’erreur métier. Remplace les deux lignes par requête de Fastify (disableRequestLogging). Voir Traçabilité.
plugins/error-handler.ts setErrorHandler Convertit ZodError, DomainError, erreurs de validation Fastify et erreurs inconnues en réponses { code, message, details? } + code HTTP. Voir détail ci-dessous.
plugins/user-session.ts onRequest Lit le cookie tep_session, résout la session utilisateur (ResolveUserSession) et la pose sur req.userSession (admin ou marchand). Dépend de @fastify/cookie.
plugins/auth.ts onRequest + preHandler Cœur de l’authentification : valide le Bearer (API key OU client token), ou synthétise une auth depuis req.userSession, et pose req.auth = { merchantId, scopes, apiKeyId, sessionId? }. Dépend de user-session.
plugins/idempotency.ts preHandler + onSend Sur méthode mutante + en-tête Idempotency-Key : rejoue une réponse 2xx déjà enregistrée, détecte les conflits de payload, persiste la réponse après coup. Voir Idempotence.
plugins/audit.ts onRequest Expose req.audit(event) ; chaque route appelle cette fonction pour journaliser une action sensible (création de clé, refund, etc.) via le use case WriteAuditLog.
plugins/require-scope.ts preHandler (fabrique) requireScope(scope) → 403 si req.auth.scopes ne contient pas le scope requis (le scope admin passe tout).
plugins/require-session-action.ts preHandler (fabrique) requireSessionAction(action) → autorise soit via le scope serveur (sessions:read/write), soit via un client token sur l’allowlist d’actions.

Le hook onRequest du plugin auth laisse passer les chemins publics (/health, /docs*, /v1/webhooks/*, /v1/auth/*) puis distingue trois sources d’identité :

1. En-tête "Authorization: Bearer <token>"
├─ token = client token (issu de /client-token) -> scopes = ['sessions:client'], sessionId lié
└─ sinon token = API key -> AuthenticateApiKey.execute(token) -> merchantId + scopes
2. Pas de Bearer mais cookie de session valide (req.userSession)
-> synthèse d'une auth : admin (scope 'admin') OU marchand (scopes dérivés du rôle owner/developer/ops)
3. Ni l'un ni l'autre -> 401 UNAUTHORIZED

Le second hook preHandler du plugin auth verrouille les client tokens : si req.auth.sessionId est présent, la requête doit cibler une route /v1/sessions/:id/* dont le :id correspond exactement au sessionId du token, sinon 403. C’est ce qui empêche un token navigateur d’agir sur une autre session. Voir Sécurité.

Une seule fonction setErrorHandler normalise toutes les sorties d’erreur :

Type d’erreur Code HTTP Corps
ZodError 400 { code: 'VALIDATION_ERROR', message, details: flatten() }
DomainError (domain/shared/errors.ts) via table STATUS_BY_CODE { code, message, details? } (details filtrés en prod)
Erreur de validation Fastify (.validation) 400 { code: 'VALIDATION_ERROR', ... }
Autre / inconnue statusCode ?? 500 message masqué en Internal server error si ≥ 500

La table STATUS_BY_CODE mappe : VALIDATION_ERROR/CURRENCY_MISMATCH → 400, INVALID_STATE_TRANSITION/INVARIANT_VIOLATION → 409, INSUFFICIENT_BALANCE → 422, PROVIDER_ERROR → 502, GIFT_PROVIDER_NOT_CONFIGURED → 409, GIFT_CREDENTIALS_REJECTED → 422, GIFT_PROVIDER_UNREACHABLE → 503, LOGS_DISABLED → 503, LOGS_UNAVAILABLE → 502. En production, sanitizeDetails n’autorise qu’une whitelist de champs par code (ex. currentState/expectedState), pour éviter de fuiter des IDs internes inter-tenant.


Chaque module de route est une fabrique xxxRoutes(deps) qui retourne un plugin Fastify et reçoit ses use cases depuis le conteneur. Les routes typées utilisent app.withTypeProvider<ZodTypeProvider>() pour brancher les schémas Zod. Préfixe commun : /v1 (sauf /health).

Colonne « auth » : public = pas de Bearer requis ; scope:X = requireScope('X') ; session-action:X = requireSessionAction('X') (API key OU client token autorisé) ; cookie = résolu via session utilisateur ; — = authentifié mais sans garde de scope supplémentaire (/v1/me, /v1/merchants/me, /v1/audit-logs).

Fichier Méthode + Path Use case Auth
routes/health.routes.ts GET /health (inline) { status, uptime } public
routes/me.routes.ts GET /v1/me (inline) renvoie req.auth —
Méthode + Path Use case Auth
POST /v1/sessions createSession scope:sessions:write
GET /v1/sessions listSessions — filtres status, reference (exact), q (« contient », référence ou métadonnées), from, to, curseur scope:sessions:read
GET /v1/sessions/:id getSession session-action:session:read
POST /v1/sessions/:id/legs/gift addGiftLeg (+ getSession) session-action:session:add_gift_leg
POST /v1/sessions/:id/legs/psp setPspLeg (+ getSession) session-action:session:add_psp_leg
DELETE /v1/sessions/:id/legs/:legId removeLeg (+ getSession) session-action:session:remove_leg
POST /v1/sessions/:id/gift-cards/check-balance checkGiftBalance session-action:session:check_balance
POST /v1/sessions/:id/legs/psp/confirm confirmPspAction scope:sessions:write
POST /v1/sessions/:id/submit submitSession session-action:session:submit
POST /v1/sessions/:id/client-token (inline) signSessionToken scope:sessions:write
POST /v1/sessions/:id/refund refundSession (+ req.audit) — voir Remboursement scope:sessions:write
GET /v1/sessions/:id/events listEvents — chronologie du journal métier, cloisonnée par marchand (routes/events.routes.ts) scope:sessions:read
PATCH /v1/sessions/:id/metadata updateSessionMetadata (+ req.audit) — fusion superficielle, convention metadata.order = { id, number, url? } posée par le plugin après création de la commande scope:sessions:write
GET /v1/sessions/export.csv exportSessionsCsv en flux (routes/sessions-export.ts), format=excel-fr|standard, fenêtre ≤ 1 an (367 jours) scope:sessions:read
GET /v1/admin/sessions/export.csv idem, tous marchands ou merchantId scope:admin

POST /v1/sessions/:id/refund rembourse des moyens de paiement, chacun en entier et sur son support d’origine. Il n’existe pas de remboursement d’une fraction d’un moyen de paiement.

Corps (RefundBodySchema, schéma strict) :

Champ Forme Effet
reason string, 1 à 255 caractères, facultatif Motif, reporté dans le journal
legIds tableau de 1 à 20 identifiants, facultatif Moyens de paiement à rembourser. Absent : tous les moyens encaissés
strategy facultatif, déprécié Seule la valeur all reste acceptée

Refus en 400 :

  • strategy: 'gift_first' ou 'cb_first' — message REFUND_STRATEGY_DEPRECATED. Ces valeurs ne faisaient que réordonner les legs : tout était remboursé quel que soit le choix ;
  • legIds et strategy ensemble ;
  • toute clé inconnue dans le corps (un montant, un ancien champ).

La sélection est validée en bloc avant tout effet. Un identifiant étranger à la session donne 400 ; un leg qui n’est pas CAPTURED donne 409. Dans les deux cas rien n’est remboursé. Les legs non désignés restent encaissés et remboursables plus tard.

Réponse (RefundResponseSchema) :

{
"sessionId": "…",
"status": "PARTIALLY_REFUNDED",
"refundedLegIds": ["…"],
"refundedAmount": 2500,
"currency": "EUR",
"outboxIds": ["…"]
}

refundedAmount est la somme des legs remboursés, en centimes. Le remboursement lui-même est asynchrone : une entrée d’outbox REFUND par leg. Après chaque leg remboursé, le worker prévient le marchand par webhook. Voir L’orchestrateur.

Cartes cadeaux standalone — routes/gift-cards.routes.ts

Section intitulée « Cartes cadeaux standalone — routes/gift-cards.routes.ts »

Endpoints « hors session » qui pilotent le GiftCardProvider du marchand authentifié (pour les surfaces Shopify où Shopify fait le split de tender). Un marchand sans accès enregistrés reçoit 409 GIFT_PROVIDER_NOT_CONFIGURED.

Méthode + Path Use case Auth
POST /v1/gift-cards/check-balance lookupGiftBalance scope:sessions:read
POST /v1/gift-cards/debit debitGiftCard scope:sessions:write
POST /v1/gift-cards/refund refundGiftCard scope:sessions:write

debit/refund exigent un Idempotency-Key (400 IDEMPOTENCY_KEY_REQUIRED sinon). Une erreur transport émetteur (TITREO_HTTP/TITREO_UNKNOWN) est renvoyée en 502 non mis en cache (donc rejouable), les refus métier déterministes en 200.

Fichier Méthode + Path Use case Auth
routes/bootstrap.routes.ts GET /v1/sessions/:id/bootstrap generateManifest (+ lecture merchant/pspConfigs) session-action:session:read
routes/webhooks.routes.ts POST /v1/webhooks/:providerType/:merchantId handlePspWebhook public (signature vérifiée dans le use case)
Fichier Méthode + Path Use case Auth
routes/merchants-me.routes.ts GET /v1/merchants/me getMerchant —
routes/merchants-me.routes.ts GET /v1/merchants/me/onboarding getMerchantOnboardingState —
routes/merchants-me.routes.ts PUT /v1/merchants/me updateMerchant (+ audit) scope:webhooks:write
routes/api-keys.routes.ts POST /v1/merchants/me/api-keys createApiKey (+ audit) scope:api_keys:write
routes/api-keys.routes.ts GET /v1/merchants/me/api-keys listApiKeys scope:api_keys:read
routes/api-keys.routes.ts DELETE /v1/merchants/me/api-keys/:id revokeApiKey (+ audit) scope:api_keys:write
routes/branding.routes.ts GET /v1/merchants/me/branding getBranding scope:branding:read
routes/branding.routes.ts PUT /v1/merchants/me/branding updateBranding (+ audit) scope:branding:write
routes/psp-configs.routes.ts GET /v1/merchants/me/psp-configs listPspConfigs scope:psp:read
routes/psp-configs.routes.ts GET /v1/merchants/me/psp-configs/:provider getPspConfig scope:psp:read
routes/psp-configs.routes.ts PUT /v1/merchants/me/psp-configs/:provider upsertPspConfig (+ valid. format + audit) scope:psp:write
routes/psp-configs.routes.ts DELETE /v1/merchants/me/psp-configs/:provider deletePspConfig (+ audit) scope:psp:write
routes/gift-configs.routes.ts GET /v1/merchants/me/gift-configs listGiftConfigs scope:psp:read
routes/gift-configs.routes.ts GET /v1/merchants/me/gift-configs/:provider getGiftConfig scope:psp:read
routes/gift-configs.routes.ts PUT /v1/merchants/me/gift-configs/:provider upsertGiftConfig (vérification en direct + audit), 10 requêtes par minute scope:psp:write
routes/gift-configs.routes.ts DELETE /v1/merchants/me/gift-configs/:provider deleteGiftConfig (+ audit) scope:psp:write
routes/merchant-users.routes.ts GET /v1/merchants/me/users listMerchantUsers scope:users:read
routes/merchant-users.routes.ts POST /v1/merchants/me/users/invites inviteMerchantUser (+ audit) scope:users:write
routes/merchant-users.routes.ts DELETE /v1/merchants/me/users/invites/:id revokeMerchantInvite (+ audit) scope:users:write
routes/merchant-users.routes.ts PATCH /v1/merchants/me/users/:id updateMerchantUser (+ audit) scope:users:write
routes/webhook-deliveries.routes.ts GET /v1/merchants/me/webhook-deliveries repo.list (port direct) scope:webhooks:read
routes/webhook-deliveries.routes.ts GET /v1/merchants/me/webhook-deliveries/:id repo.findById scope:webhooks:read
routes/webhook-deliveries.routes.ts POST /v1/merchants/me/webhook-deliveries/:id/replay outbox.enqueue (MERCHANT_WEBHOOK) scope:webhooks:write

webhook-deliveries est l’un des rares modules qui appelle des ports directement (WebhookDeliveriesRepository, OutboxRepository) plutôt qu’un use case dédié : le « replay » fabrique une entrée d’outbox MERCHANT_WEBHOOK avec une idempotencyKey déterministe. La pagination utilise un curseur opaque base64url { createdAt, id } (helpers encodeCursor/decodeCursor).

Fichier Méthode + Path Use case Auth
routes/admin-merchants.routes.ts POST /v1/admin/merchants createMerchant (+ audit) scope:admin
routes/admin-merchants.routes.ts GET /v1/admin/merchants listMerchants scope:admin
routes/admin-merchants.routes.ts GET /v1/admin/merchants/:id getMerchant scope:admin
routes/admin-merchants.routes.ts PATCH /v1/admin/merchants/:id adminUpdateMerchant (+ audit) scope:admin
routes/admin-api-keys.routes.ts POST /v1/admin/merchants/:id/api-keys createApiKey (+ audit) scope:admin
routes/admin-api-keys.routes.ts GET /v1/admin/merchants/:id/api-keys listApiKeys scope:admin
routes/admin-api-keys.routes.ts DELETE /v1/admin/merchants/:id/api-keys/:keyId revokeApiKey (+ audit) scope:admin
routes/admin-psp-configs.routes.ts GET /v1/admin/merchants/:id/psp-configs listPspConfigs scope:admin
routes/admin-psp-configs.routes.ts DELETE /v1/admin/merchants/:id/psp-configs/:provider deletePspConfig (+ audit) scope:admin
routes/gift-configs.routes.ts GET /v1/admin/merchants/:id/gift-configs listGiftConfigs scope:admin
routes/gift-configs.routes.ts DELETE /v1/admin/merchants/:id/gift-configs/:provider deleteGiftConfig (+ audit) scope:admin
routes/events.routes.ts GET /v1/admin/events listEvents — journal métier, tous marchands scope:admin
routes/events.routes.ts GET /v1/admin/sessions listSessions — sessions, tous marchands ou merchantId scope:admin
routes/admin-logs.routes.ts GET /v1/admin/logs searchLogs scope:admin
routes/admin-logs.routes.ts GET /v1/admin/logs/histogram logHistogram scope:admin
routes/admin-logs.routes.ts GET /v1/admin/logs/labels logServices scope:admin
routes/admin-logs.routes.ts GET /v1/admin/traces/:correlationId getTrace scope:admin
routes/admin-logs.routes.ts GET /v1/admin/sessions/:id/trace getTrace scope:admin
routes/admin-users.routes.ts GET /v1/admin/users repo.listAll (port direct) scope:admin
routes/admin-users.routes.ts POST /v1/admin/users createAdminUser (+ audit) scope:admin
routes/audit-logs.routes.ts GET /v1/audit-logs listAuditLogs — (admin voit tout / cible merchantId, marchand voit le sien)

L’administration lit et retire les accès cartes cadeaux d’un marchand ; elle ne les saisit jamais à sa place. Les routes de logs sont décrites sur Logs techniques.

Section intitulée « Authentification utilisateur (/v1/auth/* — public, par cookie) »

routes/auth.routes.ts gère le cycle de vie des sessions navigateur (Console marchand & Dashboard admin). Toutes ces routes sont publiques (préfixe /v1/auth/) avec rate-limit serré.

Méthode + Path Use case Notes
POST /v1/auth/signup signupMerchant rate-limit 3/min ; verificationLink exposé seulement si exposeVerificationLink
POST /v1/auth/verify-email verifyMerchantEmail 3/min
POST /v1/auth/accept-invite acceptMerchantInvite 3/min
POST /v1/auth/login loginMerchant 5/min ; pose le cookie tep_session
POST /v1/auth/admin/login loginAdmin 5/min ; pose le cookie tep_session
POST /v1/auth/logout logoutUser efface le cookie
GET /v1/auth/me (inline) lit req.userSession 401 si pas de session

Le cookie est posé par setSessionCookie : httpOnly, secure (selon config), sameSite: 'strict', path: '/', expiration alignée sur le token. Voir Sécurité.


Ce fichier centralise les schémas Zod de la zone sessions (les autres modules définissent leurs schémas inline). Il sert à la fois à valider l’entrée et à typer/sérialiser la sortie via le ZodTypeProvider.

Schéma Usage
CreateSessionBodySchema corps de POST /v1/sessions (reference, totalAmount entier positif, currency ∈ EUR/CHF/USD/GBP, metadata?)
SessionIdParamsSchema / SessionLegParamsSchema params :id / :id + :legId
AddGiftLegBodySchema ajout d’un leg carte cadeau (cardToken, cardLast4, emitter, availableBalance, debitAmount?, cvv?)
SetPspLegBodySchema ajout d’un leg PSP (providerType, paymentMethod)
LegResponseSchema / SessionResponseSchema DTO de sortie (forme exacte produite par les sérialiseurs)
RefundBodySchema corps strict de refund : reason?, legIds?, strategy? (déprécié)
SubmitResponseSchema / RefundResponseSchema retours submit / refund
ListSessionsQuerySchema / ListSessionsResponseSchema filtres de liste (status, reference, from/to, limit, cursor) + page
ErrorResponseSchema enveloppe d’erreur partagée { code, message, details? }

Les types SessionResponse et LegResponse sont inférés (z.infer<...>) et importés par les sérialiseurs : la forme du DTO et son schéma de validation ne peuvent pas diverger.

Deux fonctions pures convertissent les entités domaine en DTO HTTP. C’est la frontière de sortie : aucune entité du domaine ne fuit telle quelle vers le client.

  • serializeLeg(leg: PaymentLeg): LegResponse — appelle leg.snapshot() puis aplatit amount/currency, expose les champs provider, balances, requiresAction/actionUrl, et convertit les dates en ISO 8601.
  • serializeSession(session: PaymentSession): SessionResponse — aplatit totalAmount/remainingAmount, expose status, metadata, dates ISO, et mappe session.legs via serializeLeg.
// Extrait : la sortie HTTP est toujours un DTO, jamais l'agrégat brut.
export function serializeSession(session: PaymentSession): SessionResponse {
return {
id: session.id,
totalAmount: session.totalAmount.amount, // Money -> entier
remainingAmount: session.remainingAmount.amount,
status: session.status,
expiresAt: session.expiresAt.toISOString(), // Date -> ISO
legs: session.legs.map(serializeLeg),
// ...
}
}

POST /v1/sessions/:id/refund (refund croisé d'une session)
│
├─ onRequest request-context -> pose req.correlationId, ouvre le contexte ambiant
├─ onRequest user-session -> (si cookie) pose req.userSession
├─ onRequest auth -> valide Bearer / synthétise -> req.auth { merchantId, scopes }
├─ onRequest audit -> expose req.audit(...)
├─ preHandler call-log -> pose sessionId dans le contexte ambiant
├─ preHandler auth -> (client token) vérifie sessionId == :id, sinon 403
├─ preHandler idempotency -> si Idempotency-Key déjà vu : rejoue la réponse 2xx
├─ preHandler requireScope('sessions:write') -> 403 si scope manquant
│
├─ handler (sessions.routes.ts)
│ 1. validation Zod du body (RefundBodySchema) [parse]
│ 2. assertOwnedSession(...) -> 404 si pas le bon marchand
│ 3. container.useCases.refundSession.execute() [use case : orchestration + outbox]
│ 4. req.audit({ action: 'session.refund', ... })
│ 5. reply.send({ sessionId, status, refundedLegIds, refundedAmount, currency, outboxIds }) [sérialise]
│
├─ onSend idempotency -> persiste la réponse 2xx pour rejeu futur
└─ onResponse call-log -> écrit la ligne http_in

Pour la suite de l’exécution (use case → domaine → outbox → workers), voir L’orchestrateur, Outbox & orchestration, Machines à états et Workers. Pour la persistance, voir Couche Infrastructure et Base de données. Vue d’ensemble : Parcours d’appel.