@design-edito/publisher
v0.1.15
Published
`lm-publisher`, est serveur qui ne répond que du JSON ou des fichiers, pas de frontend.
Readme
LM-Publisher
lm-publisher, est serveur qui ne répond que du JSON ou des fichiers, pas de frontend.
API
Il y a pour l'instant 5 types de réponses : void, file, json, list, error. Si le serveur ne répond pas une réponse de type error, c'est que l'opération a abouti. Ci-dessous, d'abord le détail des réponses HTTP avec les headers avant de détailler la forme de la donnée JSON reçue pour les réponses de type json, list et error.
Les réponses HTTP
Type VOID
Une réponse de type void, est une réponse vide avec un statut HTTP 204, ce sont des opérations qui ne nécessitent pas de récupérer de la donnée en réponse. Ça ne va pas concerner beaucoup de routes normalement, typiquement :
POST /auth/logoutPOST /auth/logout-everywherePOST /auth/refresh-token (celle là elle est un peu spéciale, more on that later)POST /auth/request-email-verification-tokenPOST /auth/submit-new-password- Quelques autres dans la section
/storagedu serveur, mais cette partie là est encore expérimentale, on va pas en avoir besoin tout de suite
Le détail de comment la réponse est formée :
// Headers par défaut pour chaque route (possiblement overridées plus bas en fonction du type de réponse)
res.setHeader('Cache-Control', 'no-store')
.setHeader('Content-Security-Policy', `default-src 'none';`)
.setHeader('Referrer-Policy', 'no-referrer')
.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
.setHeader('X-Content-Type-Options', 'nosniff')
.setHeader('X-Request-ID', initMeta.requestId)
.setHeader('X-Response-Time', `${Date.now() - initMeta.timestamp}ms`)
.setHeader('X-Server-Meta', JSON.stringify({
userId: initMeta.userId,
requestId: initMeta.requestId,
timestamp: initMeta.timestamp
}))
// Fin des headers par défaut
.status(204)
.end()Type FILE
Lorsque le serveur répond par un fichier.
Note pour plus tard : pour l'instant elle n'est implémentée nulle part, mais on va en faire usage pour nos générateurs d'image, et après réflexion, on va pas passer par un truc intermédiaire de genre la route renvoie une liste d'urls temporaires sur lesquelles trouver les fichiers, on va effectivement répondre un zip, j'ai vu qu'on pouvait unzip côté client et récupérer le contenu des images, donc on va faire comme ça je pense. Anyway, la réponse est créée comme ça :
// Headers par défaut, puis
res.status(fileResponse.httpStatus)
.type(fileResponse.contentType)
.setHeader('Content-Disposition', `attachment; filename="${fileResponse.filename}"; filename*=UTF-8''${encodeURIComponent(fileResponse.filename)}`)
.setHeader('Cache-Control', fileResponse.cacheControl)
.setHeader('Content-Length', fileResponse.contentLengthBytes)Ensuite, soit le fichier est envoyé d'un bloc via res.send(fileBuffer) parce que c'est un buffer côté serveur, soit il est "streamé" via fileStream.pipe(res).
Qu'est-ce que ça veut dire ? Aucune idée.
Type JSON et LIST
Pour la plupart des routes, le serveur va répondre du JSON :
- soit un seul objet (type
json) :// Réponse JSON, headers par défaut, puis res.status(fullJsonResponse.httpStatus) .type('application/json; charset=utf-8') .setHeader('Content-Disposition', 'inline') .json(fullJsonResponse) - soit une liste d'objets partielle et paginée (type
list) :// Réponse LIST, headers par défaut, puis res.status(fullListResponse.httpStatus) .type('application/json; charset=utf-8') .setHeader('Content-Disposition', 'inline') .setHeader('X-Total-Count', fullListResponse.total) .setHeader('X-Page', fullResponse.page) .setHeader('X-Next-Page', fullListResponse.next ?? '') .setHeader('X-Prev-Page', fullListResponse.prev ?? '') .json(fullListResponse)
Type ERROR
À chaque fois que quelque chose s'est mal passé, soit côté client (auth manquante, requête malformée, ...), soit côté serveur (mauvaise implémentation, db non accessible, etc...) :
// Headers par défaut
res.status(fullResponse.httpStatus)
.type('application/problem+json')
.setHeader('Content-Disposition', 'inline')
.json(fullResponse)Le contenu des réponses JSON, LIST et ERROR
Quand le serveur renvoie autre chose qu'une réponse void ou file, il renvoie du JSON dans response.body. La structure sera toujours :
type ServerErrorResponse<C extends Codes> = {
success: false
httpStatus: number
type: 'error'
error: ErrorData<C> // Voir plus bas
meta: {
userId: string | null
requestId: string
timestampMs: number
elapsedMs: number
}
}
type ServerJsonResponse<P extends object> = {
success: true
httpStatus: number
type: 'json'
payload: P
meta: {
userId: string | null
requestId: string
timestampMs: number
elapsedMs: number
}
}
type ServerListResponse<P extends object> = {
success: true
httpStatus: number
type: 'list'
payload: P[]
page: number
total: number
prev: string | null
next: string | null
meta: {
userId: string | null
requestId: string
timestampMs: number
elapsedMs: number
}
}Ce qui compte, c'est le contenu de response.body.error ou response.body.payload, et éventuellement les détails de pagination pour les réponses list: page, total, prev (null si première), next (null si dernière).
Routes, Méthodes, Requêtes et Réponses
Toute la partie API du serveur est définie à partir d'ici : https://github.com/lm-design-edito/lm-publisher/tree/master/src/api
Dans /api/index.ts, il y a :
enum ENDPOINT = {
// Liste des endpoints sous la forme METHOD:PATH, ex: GET:/system/status-check, POST:/auth/login, etc...
}
const ROUTES = {
[ENDPOINT]: handlerFunction
}Ensuite, chaque handler est défini dans son sous-dossier dans /api, exemple : /api/auth/login
Pour chaque sous-dossier de handler, il y a :
index.ts— Rien d'intéressant icioperation.ts— La fonction qui est éxécutée pour effectuer l'opération. On y trouve normalement toujours :const silentOperation, le cœur de la fonction éxécutéeconst operation, la fonction embarquant les logs qu'elle impliquetype OperationErrorCodes, la liste des codes d'erreurs que peut renvoyer l'opérationtype DTO, le "data transfer object", c'est à dire la forme de l'objet qu'on va trouver dansresponse.body.payload, si la réponse est de typejsonoulist
validation.ts— Optionnel, la fonction qui s'occupe de valider l'input utilisateur avant de la passer àoperation.ts. On y trouve :type ExpectedBody, qui décrit la forme de l'objet à envoyer viarequest.bodypour passer la validation- Plus tard, potentiellement
type ExpectedQuery, je ne vois pas trop de cas d'usage à transmettre de la donnée via les query params de l'URL, à part pour une route qui répondrait une liste,?page=2pourrait avoir du sens.
authentication.ts— Optionnel, la fonction qui s'occupe de savoir si l'utilisateur qui fait la reqûete a les droits suffisants. Normalement assez straigntforward, ça ressemblera très souvent à ça : /api/storage/credentials/create/authentication.ts
Les codes d'erreur
La liste des codes d'erreurs que peut renvoyer le serveur en cas de réponse de type error, ainsi que le message et l'objet de détails qui y sont associés (ErrorData<Code>) est trouvable sur : /errors/index.ts
Authentification
L'authentification d'un client lorsqu'il envoie une requête au serveur repose sur deux concepts :
- Des JSON Web Tokens, avec une logique de access & refresh tokens
- Une protection contre les attaques CSRF, via le package NPM csurf, qui... a été archivé la veille de la rédaction de ce README 😭😭😭😭. Il n'y a pas trop de raison de bouger pour l'instant et surtout pas d'autre solution viable donc on va continuer et régler ça plus tard.
CSRF
Que l'on fasse une requête sur un endpoint qui demande l'authentification ou non, si la requête n'est pas en GET, la protection CSRF va s'interposer en premier, et par défaut, elle va répondre une réponse de type error, avec le code invalid-csrf-token.
Avant toute requête hors GET, il faut donc aller récupérer le token généré par le serveur, sur /csrf/get-token. Dans l'idée :
const res = await fetch('https://<hostname>/csrf/get-token', { credentials: 'include' })
const data = await res.json()
const csrfToken = data.payload.tokenEnsuite, le token vit uniquement dans la mémoire, dans une variable, et disparait donc quand l'onglet ou le navigateur est fermé. Une fois le token récupéré, les requêtes doivent embarquer le header 'X-CSRF-Token': csrfToken, et spécifier credentials: 'include'. Ex:
await fetch('https://<hostname>/some/path', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'include',
body: JSON.stringify({ ... })
})Le token a une durée de vie de 2h, il faut aller le générer à nouveau sur /csrf/get-token à chaque fois que le serveur répond invalid-csrf-token (en limitant les retries successifs, si la procédure ne fonctionne pas du premier coup, c'est probablement qu'il y a un problême côté serveur).
JWT
Dans l'idée, lorsqu'un client va s'authentifier sur /auth/login, si l'authentification réussit, le serveur va répondre avec :
- Un access token via le Header
X-Access-Token:res.setHeader('X-Access-Token', `Bearer ${token}`) - Un refresh token via le cookie
refreshToken:res.cookie('refreshToken', token, { httpOnly: true, secure: MODE === 'production', sameSite: 'none', expires: new Date(Date.now() + REFRESH_TOKEN_EXPIRATION_SECONDS * 1000) })
Une fois ces tokens récupérés, le client doit idéalement persister la valeur de l'access token dans localStorage ou sessionStorage. Il peut le garder uniquement en mémoire dans une variable comme le token CSRF, mais en revanche il ne doit SURTOUT PAS être persisté via un cookie. La valeur de cet accessToken est ensuite utilisée pour chaque requête (du moins celles qui nécessitent authentification, mais c'est bien de le mettre tout le temps) :
await fetch('https://<hostname>/some/path', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ ... }),
credentials: 'include'
})Si l'accessToken a expiré, le serveur ne va pas forcément répondre par une erreur explicite. Le middleware chargé de récupérer le contenu du token dans le header va juste considérer que la requête vient d'un utilisateur qui n'est pas authentifié, laisse passer la requête qui continue sa route vers le handler correspondant à l'URL demandée, qui lui se charge de répondre que l'utilisateur n'est pas authentifié si la route est protégée, via le code d'erreur user-not-authenticated.
Il faut donc à ce moment demander un renouvellement de l'accessToken, sur /auth/refresh-token. Pour ce faire, pas besoin de se préoccuper de la valeur du refreshToken : le serveur nous l'a fourni initialement via un cookie dont le contenu n'est pas accessible à JavaScript, et sera automatiquement inclus dans la requête :
await fetch('https://<hostname>/auth/refresh-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'include'
})[WIP] à continuer, détails sur les erreurs retournées par /auth/refresh-token, etc...
