@msbci/form-storage
v0.1.1
Published
Stockage des pieces jointes de formulaire — contrat, connecteurs S3 et local, fabrique de routeur
Readme
@msbci/form-storage
Stockage des pièces jointes d'un formulaire, hors de la ligne enregistrée.
Une pièce déposée dans un formulaire @msbci est, par défaut, encodée en base64 dans la valeur
de la réponse. Un binaire de 2 Mo pèse 2,67 Mo une fois encodé et voyage ainsi jusqu'à la base.
Ce paquet porte le contrat qui permet de l'envoyer ailleurs : dans un stockage objet, sur un
disque, ou vers un service qui possède déjà les pièces de l'application hôte.
Code serveur uniquement. Aucun secret de stockage ne descend dans le navigateur : celui-ci ne parle jamais à un connecteur, il parle à une route de l'hôte.
Série 0.x — ce à quoi s'en tenir
Ce paquet sort délibérément en 0.1.0, et pas en 1.0.0.
Le contrat est conçu pour être satisfait aussi bien par un connecteur qui écrit dans un magasin que par un connecteur qui appelle un service. Cette seconde espèce est éprouvée ici par un double de test (voir plus bas), mais elle ne le sera contre une implémentation réelle qu'au moment où un hôte en branchera une. Sortir en 1.x figerait un contrat que rien n'a encore confronté au terrain, et imposerait un incrément majeur au premier ajustement.
Une série 0.x annonce honnêtement que le contrat peut encore bouger. Le passage en 1.0.0 interviendra lorsqu'un connecteur délégant réel l'aura validé. D'ici là, épinglez une version exacte.
Installation
npm install @msbci/form-storageLe connecteur S3 charge son SDK à la demande. Il n'est nécessaire que si vous l'utilisez :
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presignerLe contrat
interface IStorageAdapter {
readonly code: string
readonly capabilities: {
signedUrl: boolean // sait produire un lien temporaire
remove: boolean // accepte une suppression a la demande
list: boolean // sait enumerer, donc se laisser rapprocher
}
put(input: IPutRequest): Promise<IStoredFile>
getStream(ref: string): Promise<IReadableFile> // seule lecture obligatoire
head(ref: string): Promise<IStoredFile | null>
getSignedUrl?(ref: string, options?: ISignedUrlOptions): Promise<ISignedUrl>
remove?(ref: string): Promise<void>
list?(scope: Partial<IStorageScope>): AsyncIterable<IStoredObject>
}Dix règles le gouvernent, et trois d'entre elles ne sont pas négociables.
- Aucune notion de compartiment, de clef, de chemin ni de région dans la surface publique. L'appelant ne nomme jamais l'endroit : il remet un contenu et reçoit un identifiant opaque.
- L'identifiant est frappé par le connecteur, jamais par l'appelant, jamais dérivé du nom du
fichier. C'est ce qui interdit à deux déposants d'
attestation.pdfde se recouvrir, et ce qui empêche un nom hostile de déplacer l'objet. - Toutes les opérations sont asynchrones et faillibles, avec une famille d'erreurs qui distingue « refusé », « introuvable » et « le stockage n'a pas répondu ». Un connecteur distant échoue pour des raisons qu'un connecteur local ne connaît pas.
- Le contenu voyage en flux, jamais en chemin de fichier.
- Les capacités sont déclarées, pas supposées. (non négociable)
getStreamest la seule opération de lecture obligatoire. (non négociable) Le lien signé est une optimisation ; le chemin universel est « l'hôte sert les octets ». C'est aussi ce qu'imposent les politiques de sécurité de contenu strictes, qui interdisent de récupérer enfetchou d'afficher en<img>une ressource servie par un hôte tiers.- La suppression peut être refusée. (non négociable) Un connecteur a le droit de répondre « le cycle de vie ne m'appartient pas ». Sans cette règle, le contrat obligerait un connecteur délégant à contredire le service qui fait autorité.
- La portée de dépôt embarque un contexte opaque à l'hôte, transporté jusqu'au connecteur sans jamais être interprété par le paquet.
- Une clef d'idempotence accompagne le dépôt. Un envoi rejoué sur une liaison lente ne crée pas deux objets.
- Aucune énumération n'est supposée. Le rapprochement stockage / base est facultatif.
La valeur portée par une soumission
interface IStoredFile {
kind: 'stored-file' // discriminant explicite : distingue d'une data URL
ref: string // identifiant opaque rendu par le connecteur
fileName: string // nom d'origine, conserve tel quel
contentType: string // type retenu par le connecteur, pas celui annonce par le client
size: number
checksum?: { algo: 'sha256'; value: string }
uploadedAt: string // ISO 8601
adapter: string // code du connecteur qui a ecrit
}ref est opaque : ni chemin, ni clef, ni URL. Personne d'autre que le connecteur ne l'interprète.
adapter existe parce qu'un changement de cible n'est pas rétroactif : un fichier déposé sous
une cible reste lu par le connecteur qui l'a écrit, même après bascule. Sans ce champ, changer de
cible rendrait illisibles tous les fichiers déjà déposés, silencieusement.
Nom unique côté stockage, nom d'origine conservé. Deux déposants qui envoient attestation.pdf
obtiennent deux références distinctes, et chacun retrouve son fichier sous son nom d'origine — le
nom voyage dans les métadonnées et revient en Content-Disposition, jamais dans l'identifiant.
La portée
interface IStorageScope {
tenantId?: string
formId: string
submissionId?: string // absent tant que la soumission n'existe pas
variableCode: string
instanceNumber?: number
context?: Record<string, string> // opaque : le paquet ne l'interprete jamais
}Le vocabulaire est celui d'un formulaire, et rien d'autre. context est le point charnière :
l'hôte y dépose ce que son connecteur doit savoir — l'identifiant d'un objet métier, par exemple —
et le paquet le transporte sans jamais le lire.
Les erreurs
| Erreur | Code | Sens |
|---|---|---|
| StorageRejectedError | STORAGE_REJECTED | Refus : size, content-type, forbidden, invalid. Non retentable. |
| StorageNotFoundError | STORAGE_NOT_FOUND | La référence ne désigne rien. |
| StorageUnavailableError | STORAGE_UNAVAILABLE | Le stockage n'a pas répondu. Retentable. |
| StorageUnsupportedError | STORAGE_UNSUPPORTED | Capacité non offerte. Ce n'est pas une panne. |
Les connecteurs livrés
S3 et compatibles
import { createS3StorageAdapter } from '@msbci/form-storage'
const adapter = createS3StorageAdapter({
bucket: process.env.STORAGE_BUCKET!,
endpoint: process.env.STORAGE_ENDPOINT,
region: process.env.STORAGE_REGION,
accessKeyId: process.env.STORAGE_ACCESS_KEY!,
secretAccessKey: process.env.STORAGE_SECRET_KEY!,
forcePathStyle: true,
// La signature couvre l'hote : un lien signe sur l'adresse interne du service
// n'est pas valable depuis un navigateur.
publicEndpoint: process.env.STORAGE_PUBLIC_ENDPOINT,
maxBytes: 10 * 1024 * 1024,
allowedContentTypes: ['application/pdf', 'image/jpeg', 'image/png'],
})Capacités : signedUrl, remove, list — les trois.
Deux points de mise en œuvre méritent d'être connus.
- Les métadonnées utilisateur d'un objet S3 sont ASCII. Un nom de fichier en UTF-8 y est encodé en pourcentage et décodé à la lecture. Omis, ce détail produit un nom cassé six mois plus tard.
- La lecture est un flux de bout en bout ; l'écriture, elle, lit la source jusqu'à
maxBytesavant d'émettre l'objet, la taille et l'empreinte devant accompagner ses métadonnées. La mémoire est donc bornée par l'exploitant, jamais par l'appelant : le dépassement est refusé pendant la lecture, pas après.
Stockage local
import { createLocalStorageAdapter } from '@msbci/form-storage'
const adapter = createLocalStorageAdapter({ rootDir: '/var/lib/form-attachments' })Capacités : remove et list. Pas de lien signé — l'hôte sert les octets.
Utile en développement et sur un déploiement à une seule instance. Sans volume partagé, il ne survit ni au remplacement d'un conteneur ni à une seconde instance : la moitié des fichiers devient invisible, sans erreur. C'est écrit ici parce que la panne, elle, ne le dira pas.
L'écriture se fait sous nom temporaire puis renommage : un lecteur ne tombe jamais sur un fichier à moitié écrit.
La fabrique de routeur
createStorageRouter rend un gestionnaire indépendant du cadriciel, à monter sur Express, Fastify
ou une route Next.js — sur le modèle de createFormRouter.
import { createStorageRouter } from '@msbci/form-storage'
const router = createStorageRouter({
adapter,
authenticate: async (request) => resolveSession(request.headers),
resolveScope: async (request, identity) => ({
formId: String(request.query.formId),
variableCode: String(request.query.variableCode),
// L'hote lie ce qu'il veut : le paquet ne lira jamais ce contexte.
context: { orderId: identity.orderId as string },
}),
authorizeRef: async (ref, action, identity) => canAccess(identity, ref, action),
})| Route | Effet |
|---|---|
| POST /files | Dépôt. Nom d'origine en en-tête x-file-name (encodé en pourcentage) ou en paramètre fileName. Clef d'idempotence en x-idempotency-key. |
| GET /files/:ref | Sert les octets en même origine, sous le nom d'origine. ?download=1 force le téléchargement. |
| GET /files/:ref/metadata | Rend la référence complète. |
| GET /files/:ref/signed-url | Lien temporaire, si le connecteur l'offre. 405 sinon. |
| DELETE /files/:ref | Suppression, si le connecteur l'accepte. 405 sinon. |
Trois crochets sont obligatoires, et aucun n'a de valeur par défaut permissive :
authenticate— un retournullvaut refus. Le routeur sert des pièces, il n'a pas de mode ouvert.resolveScope— la portée est décidée par l'hôte à partir de son propre routage, jamais lue dans le corps de la requête.authorizeRef— une référence n'est pas un droit. Détenir l'identifiant d'un fichier ne prouve rien ; l'hôte seul sait à quel objet métier il se rattache. Un refus est formulé en404, pas en403: l'appelant ne doit pas apprendre que la référence existe.
Éprouver un connecteur
Tout connecteur — livré ici ou écrit par un hôte — doit passer la suite de conformité. Elle est
publiée sous @msbci/form-storage/testing et ne dépend d'aucun cadriciel de test : elle rend
une liste de contrôles nommés, que vous déroulez avec l'outil de votre choix.
Ce sous-chemin désigne le même module que la racine, et non un second paquet compilé. C'est
délibéré : deux bundles porteraient chacun leur copie des classes d'erreur, et instanceof serait
faux entre l'appelant et la suite de conformité — une panne discrète et pénible à diagnostiquer.
Les mêmes symboles sont donc importables depuis @msbci/form-storage.
import { storageConformanceChecks } from '@msbci/form-storage/testing'
for (const check of storageConformanceChecks({ adapter: monConnecteur, maxBytes: 6 * 1024 * 1024 })) {
it(check.name, () => check.run())
}Ou, sans cadriciel :
import { runStorageConformance } from '@msbci/form-storage/testing'
const report = await runStorageConformance({ adapter: monConnecteur })
console.log(`${report.passed} passes, ${report.failed} echecs`)La suite n'exige ni suppression, ni énumération, ni lien signé : un connecteur qui les refuse
passe la conformité, à condition de le déclarer dans capabilities. Elle vérifie en revanche que
la déclaration et le comportement coïncident — une capacité annoncée à true sans méthode est un
échec, une méthode présente alors que la capacité est à false doit lever
StorageUnsupportedError.
Le double « service distant »
@msbci/form-storage/testing expose aussi createRemoteServiceFixture() : un connecteur qui ne
possède rien — pas de compartiment, pas de répertoire, pas de clef d'objet — et le service qu'il
appelle. Le connecteur n'a qu'une adresse et une fonction fetch ; il ne frappe aucun
identifiant, ne reconnaît aucun type et ne calcule aucune empreinte : le service en décide, il
relaie. Il refuse par ailleurs la suppression et l'énumération.
Qu'il passe la même suite que les connecteurs S3 et local est la démonstration que le contrat tient sa promesse : il est satisfaisable par une implémentation qui appelle un service, et pas seulement par une qui écrit dans un magasin. C'est aussi la référence à lire avant d'écrire un connecteur délégant.
Compatibilité
Ce paquet est additif : il n'est consommé par aucun autre paquet @msbci à ce jour. Installer
@msbci/form-storage ne change rien au comportement de form-core, form-renderer ou
form-server, qui continuent d'encoder les pièces en base64 tant qu'un hôte ne les branche pas
explicitement.
v0.1.1
- Le paquet publie empechait le processus Node qui l'importait de se terminer. L'obfuscation posait une protection anti-debogage a intervalle, c'est-a-dire un
setIntervaljamais arrete : la boucle d'evenements restait occupee, et tout script, outil en ligne de commande ou tache d'integration continue qui importait le paquet restait suspendu jusqu'a son delai d'expiration, sans message et sans cause visible. Le defaut touchait ce paquet de plein fouet, puisqu'il n'existe que pour etre monte dans un processus serveur. La protection est retiree — elle vise la console d'un navigateur et ne protegeait rien sur une cible Node, tandis que les instructionsdebuggerqu'elle injectait interrompaient le debogage legitime d'un consommateur. Ce qui rend le code couteux a relire est conserve : tableau de chaines encode, aplatissement du flot de controle, code mort, renommage des identifiants. - Aucun changement de contrat ni de comportement.
IStoredFiletel que ce paquet le declare est desormais aussi declare par@msbci/form-corev1.13.0, a l'identique, pour que le rendu puisse lire une reference sans embarquer de code serveur. Les deux declarations sont de meme forme et se satisfont l'une l'autre : ce paquet n'importe rien du coeur, et le coeur n'importe rien d'ici. Tests inchanges.
v0.1.0
Première version. Le paquet n'a aucun consommateur : il est publiable et éprouvable seul.
- Le contrat (
IStorageAdapter,IStoredFile,IStorageScope,IPutRequest) et sa famille d'erreurs (StorageRejectedError,StorageNotFoundError,StorageUnavailableError,StorageUnsupportedError). Il est écrit pour être satisfait par un connecteur qui appelle un service autant que par un connecteur qui écrit dans un magasin : capacités déclarées, lecture en flux seule obligation, suppression et énumération refusables. - Nom unique côté stockage, nom d'origine conservé. L'identifiant est frappé par le connecteur
et ne contient rien de ce que le client transmet ; le nom d'origine voyage dans les métadonnées
et revient en
Content-Disposition. Deux dépôts successifs d'attestation.pdfne se recouvrent pas et se relisent chacun sous son nom. - Connecteur S3 et compatibles — dépôt, lecture en flux, métadonnées, lien signé à durée bornée, suppression, énumération par préfixe. Le SDK est chargé à la demande et déclaré en dépendance de pair facultative : le paquet reste installable et chargeable sans lui. Le point d'entrée de signature est dissociable du point d'entrée interne, la signature couvrant l'hôte. Les noms de fichiers UTF-8 sont encodés en pourcentage dans les métadonnées utilisateur, qui sont ASCII.
- Connecteur local — écriture sous nom temporaire puis renommage, métadonnées en fichier accompagnant, énumération par portée. Pas de lien signé : l'hôte sert les octets.
- Fabrique de routeur
createStorageRouter, indépendante du cadriciel, avec trois crochets obligatoires et sans défaut permissif : authentification, résolution de portée, et autorisation par référence — une référence n'est pas un droit, et un refus de lecture est un404. - Type réel déterminé par signature binaire. Le type annoncé par le client est confronté au
contenu, jamais retenu tel quel ; l'extension ne tranche que ce que la signature ne distingue pas
(les formats bureautiques, qui sont des archives ZIP). Liste blanche facultative par
allowedContentTypes. - Borne d'exploitation
maxBytes(défaut 10 Mio), refusée pendant la lecture du flux et non après : la mémoire du serveur ne peut pas être dictée par l'appelant. - Idempotence — une clef d'idempotence rejouée retombe sur la même référence et ne crée pas un second objet. La clef, qui vient de l'appelant, est hachée avec la portée : rien de ce que le client transmet n'atteint le chemin de l'objet.
- Suite de conformité publiée sous
@msbci/form-storage/testing, sans dépendance à un cadriciel de test, accompagnée d'un double « service distant » qui prouve que le contrat est satisfaisable sans magasin. Le sous-chemin désigne le même module que la racine : un second bundle aurait porté sa propre copie des classes d'erreur, rendantinstanceoffaux entre l'appelant et la suite. - Vérification : 104 tests. La conformité est jouée sur les trois connecteurs — S3 contre un vrai serveur compatible S3, local, et le double appelant un service. La lecture en flux est établie par mesure : lecture d'un fichier de 48 Mio en relâchant chaque morceau contre lecture conservant les morceaux, la première coûtant moins du huitième de la seconde, et un consommateur qui renonce après un morceau n'ayant pas fait lire le fichier entier.
License
Voir LICENSE.md.
