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, registres PSP/gift, signer de manifeste…).
  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, 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 ou un randomUUID), le renvoie dans la réponse.
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. 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 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) scope:sessions:write

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

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

Endpoints « hors session » qui pilotent directement le GiftCardProvider (pour les surfaces Shopify où Shopify fait le split de tender).

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/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/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)
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)
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
├─ onRequest user-session -> (si cookie) pose req.userSession
├─ onRequest auth -> valide Bearer / synthétise -> req.auth { merchantId, scopes }
├─ onRequest audit -> expose req.audit(...)
├─ 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, outboxIds }) [sérialise]
└─ onSend idempotency -> persiste la réponse 2xx pour rejeu futur

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.