@mostajs/blob-store 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,14 +50,40 @@ Erreurs **typées** (jamais nues) : `StorageError` avec un `code` (`not_found`,
50
50
  - **`FilesystemDriver`** — disque local. Sanitise les chemins (le `..` est refusé : un driver
51
51
  de stockage est une surface d'attaque).
52
52
  - **`CachingDriver`** — décorateur de cache devant un driver d'origine.
53
- - **S3**à venir (voir la feuille de route ; `@mostajs/storage` promettait « FS / S3 » sans
54
- jamais livrer S3 : la dette est soldée **ici**, pour que tous les consommateurs en profitent).
53
+ - **`S3Driver`**S3 **et tout service S3-compatible** : AWS, MinIO, Scaleway, Wasabi,
54
+ Backblaze… **Zéro dépendance** : SigV4 est implémenté ici (S3 n'est que du HTTP signé), au
55
+ lieu de tirer `@aws-sdk/client-s3` et ses dizaines de mégaoctets. Pour héberger les
56
+ sauvegardes de vos clients sur vos propres serveurs, cette souveraineté n'est pas un détail.
57
+
58
+ ```js
59
+ import { S3Driver } from '@mostajs/blob-store/drivers/s3';
60
+
61
+ const s3 = new S3Driver({
62
+ endpoint: 'http://localhost:9000', // MinIO auto-hébergé, ou https://s3.eu-west-3.amazonaws.com
63
+ region: 'eu-west-3',
64
+ accessKeyId: process.env.S3_KEY,
65
+ secretAccessKey: process.env.S3_SECRET,
66
+ forcePathStyle: true, // requis par la plupart des services auto-hébergés
67
+ });
68
+ ```
69
+
70
+ **Dette soldée** : `@mostajs/storage` annonçait « bytes in object storage (FS / **S3**) »
71
+ depuis sa 0.2.0 — sans qu'aucun pilote S3 n'existe. La promesse est tenue **ici**, au niveau
72
+ de la primitive, pour qu'`archive-box` et `backup` en profitent aussi. La poser dans
73
+ `storage` aurait payé la dette au mauvais étage.
55
74
 
56
75
  ## Tests
57
76
 
58
77
  ```bash
59
- npm test # 10 tests mjs-unit, sur un VRAI répertoire temporaire
78
+ npm test # 28 tests mjs-unit : disque réel, SigV4, serveur S3 simulé
60
79
  ```
61
80
 
62
- Un test de stockage qui n'écrit rien ne prouve rien : les tests exercent le disque réel, avec
63
- un vecteur SHA-256 **vérifié indépendamment** (et non recopié depuis le pilote lui-même).
81
+ Un test de stockage qui n'écrit rien ne prouve rien :
82
+
83
+ - **Filesystem** — disque réel, vecteur SHA-256 **vérifié indépendamment** (et non recopié
84
+ depuis le pilote lui-même).
85
+ - **SigV4** — validée contre les **vecteurs officiels d'AWS** (dérivation de clé, requête
86
+ canonique et signature de la suite « get-vanilla »). Une signature fausse ne se manifesterait
87
+ qu'à l'exécution, par un `403 SignatureDoesNotMatch` indéchiffrable sur un serveur distant.
88
+ - **S3** — serveur S3 **simulé** qui **refuse toute requête non signée** : un pilote qui
89
+ « marcherait » sans `Authorization` passerait des tests naïfs, puis échouerait chez le client.
@@ -0,0 +1,51 @@
1
+ import type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult } from '../driver.js';
2
+ import type { ObjectRef, PutObjectResult } from '../types.js';
3
+ import { type SigV4Credentials } from './sigv4.js';
4
+ export interface S3DriverConfig extends SigV4Credentials {
5
+ /** Ex. `https://s3.eu-west-3.amazonaws.com` ou `http://localhost:9000` (MinIO). */
6
+ endpoint: string;
7
+ region: string;
8
+ /**
9
+ * `true` (défaut) → `https://host/bucket/cle` ; `false` → `https://bucket.host/cle`.
10
+ * Les services auto-hébergés (MinIO) exigent généralement le style « path ».
11
+ */
12
+ forcePathStyle?: boolean;
13
+ /** Timeout par requête (ms). Défaut 30 s — une requête sans borne fige l'appelant. */
14
+ timeoutMs?: number;
15
+ }
16
+ export declare class S3Driver implements StorageDriver {
17
+ private config;
18
+ readonly id = "s3";
19
+ constructor(config: S3DriverConfig);
20
+ /** URL d'un objet (ou du bucket si `key` est vide), selon le style d'adressage. */
21
+ private url;
22
+ /** Envoie une requête signée. Traduit les statuts HTTP en `StorageError` typées. */
23
+ private send;
24
+ /**
25
+ * Écrit un objet.
26
+ *
27
+ * Le corps est mis EN MÉMOIRE avant envoi. C'est un choix assumé : SigV4 exige soit le
28
+ * hash sha256 de la charge utile, soit la signature par blocs (« chunked »), nettement plus
29
+ * complexe. Bufferiser donne un `checksum` fiable et un `Content-Length` exact — au prix de
30
+ * la mémoire. Pour de très gros objets, ce pilote appellera un jour l'upload multipart ;
31
+ * mieux vaut une limite CONNUE qu'un streaming subtilement faux.
32
+ */
33
+ put(ref: ObjectRef, body: PutBody, opts: PutOptions): Promise<PutObjectResult>;
34
+ get(ref: ObjectRef): Promise<GetObjectResult>;
35
+ delete(ref: ObjectRef): Promise<void>;
36
+ exists(ref: ObjectRef): Promise<boolean>;
37
+ stat(ref: ObjectRef): Promise<StatResult>;
38
+ list(args: {
39
+ bucket: string;
40
+ prefix?: string;
41
+ limit?: number;
42
+ cursor?: string;
43
+ }): Promise<{
44
+ items: {
45
+ path: string;
46
+ size: number;
47
+ modifiedAt: Date;
48
+ }[];
49
+ nextCursor?: string;
50
+ }>;
51
+ }
@@ -0,0 +1,219 @@
1
+ // @mostajs/blob-store — Pilote S3 (et tout service S3-compatible)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ // License: AGPL-3.0-or-later
4
+ //
5
+ // ZÉRO DÉPENDANCE : S3 n'est que du HTTP signé (SigV4). On n'embarque pas
6
+ // `@aws-sdk/client-s3` — des dizaines de Mo de dépendances pour poser et relire des octets.
7
+ //
8
+ // Conséquence heureuse : le pilote parle à TOUT service S3-compatible — AWS, mais aussi
9
+ // MinIO, Scaleway, Wasabi, Backblaze, ou un MinIO auto-hébergé. Pour des sauvegardes
10
+ // clients, cette souveraineté n'est pas un détail.
11
+ //
12
+ // DETTE SOLDÉE : `@mostajs/storage` annonçait « bytes in object storage (FS / S3) » depuis
13
+ // sa 0.2.0 — sans qu'aucun pilote S3 n'existe. La promesse est tenue ICI, au niveau de la
14
+ // primitive, pour que `@mostajs/archive-box` et `@mostajs/backup` en profitent aussi. La
15
+ // poser dans `storage` aurait payé la dette au mauvais étage.
16
+ import { StorageError } from '../types.js';
17
+ import { signRequest, hashPayload, uriEncode } from './sigv4.js';
18
+ /** Lit entièrement un corps en mémoire (voir la note sur le buffering dans `put`). */
19
+ async function toBytes(body) {
20
+ if (body instanceof Uint8Array)
21
+ return body;
22
+ const chunks = [];
23
+ for await (const chunk of body) {
24
+ chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
25
+ }
26
+ const total = chunks.reduce((n, c) => n + c.length, 0);
27
+ const out = new Uint8Array(total);
28
+ let off = 0;
29
+ for (const c of chunks) {
30
+ out.set(c, off);
31
+ off += c.length;
32
+ }
33
+ return out;
34
+ }
35
+ /** Extrait le contenu des balises `<tag>…</tag>` — suffisant pour le XML plat de S3. */
36
+ function xmlAll(xml, tag) {
37
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g');
38
+ const out = [];
39
+ let m;
40
+ while ((m = re.exec(xml)) !== null)
41
+ out.push(m[1]);
42
+ return out;
43
+ }
44
+ const xmlOne = (xml, tag) => xmlAll(xml, tag)[0];
45
+ export class S3Driver {
46
+ config;
47
+ id = 's3';
48
+ constructor(config) {
49
+ this.config = config;
50
+ if (!config.endpoint)
51
+ throw new StorageError('driver_error', 'S3Driver: endpoint requis');
52
+ if (!config.region)
53
+ throw new StorageError('driver_error', 'S3Driver: region requise');
54
+ }
55
+ /** URL d'un objet (ou du bucket si `key` est vide), selon le style d'adressage. */
56
+ url(bucket, key = '') {
57
+ const base = new URL(this.config.endpoint);
58
+ const pathStyle = this.config.forcePathStyle !== false;
59
+ const encodedKey = key ? uriEncode(key, false) : '';
60
+ const host = pathStyle ? base.host : `${bucket}.${base.host}`;
61
+ const path = pathStyle
62
+ ? `/${uriEncode(bucket, false)}${encodedKey ? `/${encodedKey}` : ''}`
63
+ : `/${encodedKey}`;
64
+ return { href: `${base.protocol}//${host}${path}`, host, path };
65
+ }
66
+ /** Envoie une requête signée. Traduit les statuts HTTP en `StorageError` typées. */
67
+ async send(args) {
68
+ const { href, host, path } = this.url(args.bucket, args.key);
69
+ const payloadHash = args.payloadHash ?? hashPayload(args.body);
70
+ const { headers } = signRequest({
71
+ req: {
72
+ method: args.method,
73
+ path,
74
+ query: args.query,
75
+ headers: { host, ...(args.headers ?? {}) },
76
+ payloadHash,
77
+ },
78
+ credentials: this.config,
79
+ region: this.config.region,
80
+ });
81
+ const qs = args.query
82
+ ? '?' + Object.keys(args.query).sort().map((k) => `${uriEncode(k)}=${uriEncode(args.query[k])}`).join('&')
83
+ : '';
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 30_000);
86
+ let res;
87
+ try {
88
+ res = await fetch(href + qs, {
89
+ method: args.method,
90
+ headers,
91
+ body: args.body,
92
+ signal: controller.signal,
93
+ });
94
+ }
95
+ catch (err) {
96
+ throw new StorageError('driver_error', `S3 ${args.method} ${args.bucket}/${args.key ?? ''} : ${err.message}`);
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ if (res.status === 404) {
102
+ throw new StorageError('not_found', `S3 : objet introuvable (${args.bucket}/${args.key ?? ''})`);
103
+ }
104
+ if (res.status === 403 || res.status === 401) {
105
+ // Cause n°1 en pratique : horloge décalée, mauvaise région, ou secret erroné.
106
+ throw new StorageError('access_denied', `S3 : accès refusé (${res.status}) — vérifier clés, région et horloge`);
107
+ }
108
+ if (res.status === 412) {
109
+ throw new StorageError('driver_error', `S3 : l'objet existe déjà (ifNotExists)`, { status: 412 });
110
+ }
111
+ if (!res.ok) {
112
+ const detail = await res.text().catch(() => '');
113
+ throw new StorageError('driver_error', `S3 ${args.method} → HTTP ${res.status}`, { body: detail.slice(0, 300) });
114
+ }
115
+ return res;
116
+ }
117
+ /**
118
+ * Écrit un objet.
119
+ *
120
+ * Le corps est mis EN MÉMOIRE avant envoi. C'est un choix assumé : SigV4 exige soit le
121
+ * hash sha256 de la charge utile, soit la signature par blocs (« chunked »), nettement plus
122
+ * complexe. Bufferiser donne un `checksum` fiable et un `Content-Length` exact — au prix de
123
+ * la mémoire. Pour de très gros objets, ce pilote appellera un jour l'upload multipart ;
124
+ * mieux vaut une limite CONNUE qu'un streaming subtilement faux.
125
+ */
126
+ async put(ref, body, opts) {
127
+ const bytes = await toBytes(body);
128
+ const checksum = hashPayload(bytes);
129
+ const headers = {
130
+ 'content-type': opts.mimeType,
131
+ 'content-length': String(bytes.length),
132
+ };
133
+ // Création atomique : S3 (et MinIO) refusent l'écriture si l'objet existe → HTTP 412.
134
+ if (opts.ifNotExists)
135
+ headers['if-none-match'] = '*';
136
+ for (const [k, v] of Object.entries(opts.metadata ?? {})) {
137
+ headers[`x-amz-meta-${k.toLowerCase()}`] = v;
138
+ }
139
+ const res = await this.send({
140
+ method: 'PUT',
141
+ bucket: ref.bucket,
142
+ key: ref.path,
143
+ body: bytes,
144
+ headers,
145
+ payloadHash: checksum,
146
+ });
147
+ const etag = (res.headers.get('etag') ?? '').replace(/"/g, '');
148
+ return { ref, size: bytes.length, etag: etag || checksum.slice(0, 32), checksum };
149
+ }
150
+ async get(ref) {
151
+ const res = await this.send({ method: 'GET', bucket: ref.bucket, key: ref.path });
152
+ if (!res.body)
153
+ throw new StorageError('driver_error', 'S3 : réponse sans corps');
154
+ const metadata = {};
155
+ res.headers.forEach((v, k) => {
156
+ if (k.startsWith('x-amz-meta-'))
157
+ metadata[k.slice('x-amz-meta-'.length)] = v;
158
+ });
159
+ return {
160
+ stream: res.body,
161
+ size: Number(res.headers.get('content-length') ?? -1),
162
+ mimeType: res.headers.get('content-type') ?? 'application/octet-stream',
163
+ etag: (res.headers.get('etag') ?? '').replace(/"/g, ''),
164
+ metadata,
165
+ };
166
+ }
167
+ async delete(ref) {
168
+ await this.send({ method: 'DELETE', bucket: ref.bucket, key: ref.path });
169
+ }
170
+ async exists(ref) {
171
+ try {
172
+ await this.send({ method: 'HEAD', bucket: ref.bucket, key: ref.path });
173
+ return true;
174
+ }
175
+ catch (err) {
176
+ if (err instanceof StorageError && err.code === 'not_found')
177
+ return false;
178
+ throw err;
179
+ }
180
+ }
181
+ async stat(ref) {
182
+ const res = await this.send({ method: 'HEAD', bucket: ref.bucket, key: ref.path });
183
+ const metadata = {};
184
+ res.headers.forEach((v, k) => {
185
+ if (k.startsWith('x-amz-meta-'))
186
+ metadata[k.slice('x-amz-meta-'.length)] = v;
187
+ });
188
+ const lastModified = res.headers.get('last-modified');
189
+ return {
190
+ size: Number(res.headers.get('content-length') ?? -1),
191
+ mimeType: res.headers.get('content-type') ?? 'application/octet-stream',
192
+ etag: (res.headers.get('etag') ?? '').replace(/"/g, ''),
193
+ // S3 renvoie `Last-Modified` en date HTTP. Absent (certains services S3-compatibles
194
+ // l'omettent sur HEAD) → epoch, plutôt qu'une `Invalid Date` qui contaminerait l'appelant.
195
+ modifiedAt: lastModified ? new Date(lastModified) : new Date(0),
196
+ metadata,
197
+ };
198
+ }
199
+ async list(args) {
200
+ const query = { 'list-type': '2' };
201
+ if (args.prefix)
202
+ query.prefix = args.prefix;
203
+ if (args.limit)
204
+ query['max-keys'] = String(Math.max(1, Math.min(args.limit, 1000)));
205
+ if (args.cursor)
206
+ query['continuation-token'] = args.cursor;
207
+ const res = await this.send({ method: 'GET', bucket: args.bucket, query });
208
+ const xml = await res.text();
209
+ // XML plat de S3 : une extraction par balises suffit, et évite une dépendance de parsing.
210
+ const items = xmlAll(xml, 'Contents').map((entry) => ({
211
+ path: decodeURIComponent(xmlOne(entry, 'Key') ?? ''),
212
+ size: Number(xmlOne(entry, 'Size') ?? 0),
213
+ modifiedAt: new Date(xmlOne(entry, 'LastModified') ?? 0),
214
+ }));
215
+ const truncated = xmlOne(xml, 'IsTruncated') === 'true';
216
+ const nextCursor = truncated ? xmlOne(xml, 'NextContinuationToken') : undefined;
217
+ return { items, ...(nextCursor ? { nextCursor } : {}) };
218
+ }
219
+ }
@@ -0,0 +1,55 @@
1
+ export interface SigV4Credentials {
2
+ accessKeyId: string;
3
+ secretAccessKey: string;
4
+ /** Jeton de session (STS / rôles temporaires). */
5
+ sessionToken?: string;
6
+ }
7
+ export interface SigV4Request {
8
+ method: string;
9
+ /** Chemin déjà encodé (ex. `/bucket/objet%20a.txt`). */
10
+ path: string;
11
+ /** Paramètres de requête (seront triés et encodés). */
12
+ query?: Record<string, string>;
13
+ headers: Record<string, string>;
14
+ /** sha256 hex de la charge utile (`UNSIGNED-PAYLOAD` accepté). */
15
+ payloadHash: string;
16
+ }
17
+ /** sha256 hex d'une charge utile (à passer en `payloadHash`). */
18
+ export declare function hashPayload(body: Uint8Array | string | undefined): string;
19
+ /**
20
+ * Encodage d'URI d'AWS : RFC 3986 STRICT. `encodeURIComponent` laisse passer `!'()*`,
21
+ * qu'AWS attend encodés — un seul caractère divergent, et la signature ne correspond plus.
22
+ */
23
+ export declare function uriEncode(str: string, encodeSlash?: boolean): string;
24
+ /** Horodatage AWS : `20260713T024500Z` et sa date courte `20260713`. */
25
+ export declare function amzDate(now: Date): {
26
+ amzDate: string;
27
+ dateStamp: string;
28
+ };
29
+ /** Clé de signature dérivée (date → région → service → aws4_request). */
30
+ export declare function signingKey(secret: string, dateStamp: string, region: string, service: string): Buffer;
31
+ /** Requête canonique — l'ordre et l'encodage sont NORMATIFS : la moindre variation casse tout. */
32
+ export declare function canonicalRequest(req: SigV4Request): {
33
+ canonical: string;
34
+ signedHeaders: string;
35
+ };
36
+ /**
37
+ * Calcule l'en-tête `Authorization` d'une requête SigV4.
38
+ * Retourne aussi les en-têtes à AJOUTER (`x-amz-date`, `x-amz-content-sha256`…).
39
+ */
40
+ export declare function signRequest(args: {
41
+ req: SigV4Request;
42
+ credentials: SigV4Credentials;
43
+ region: string;
44
+ service?: string;
45
+ now?: Date;
46
+ }): {
47
+ authorization: string;
48
+ headers: Record<string, string>;
49
+ };
50
+ /** Exposé pour les tests (vecteurs officiels AWS). */
51
+ export declare const _internal: {
52
+ sha256hex: (data: string | Uint8Array) => string;
53
+ hmac: (key: Buffer | string, data: string) => Buffer;
54
+ ALGO: string;
55
+ };
@@ -0,0 +1,105 @@
1
+ // @mostajs/blob-store — Signature AWS SigV4 (AWS4-HMAC-SHA256)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ // License: AGPL-3.0-or-later
4
+ //
5
+ // ZÉRO DÉPENDANCE. S3 n'est que du HTTP signé : inutile de tirer `@aws-sdk/client-s3`
6
+ // (des dizaines de Mo de dépendances) pour poser et relire des octets. On signe nous-mêmes,
7
+ // avec `node:crypto` — et le pilote fonctionne alors avec TOUT service S3-compatible
8
+ // (AWS, MinIO, Scaleway, Wasabi, Backblaze…), pas seulement AWS.
9
+ //
10
+ // Référence : AWS Signature Version 4 — « Create a signed request ».
11
+ // La correction de ce fichier est vérifiée contre les VECTEURS OFFICIELS d'AWS
12
+ // (test-scripts/sigv4.test.mjs) : une signature fausse échouerait en silence côté serveur,
13
+ // avec un « 403 SignatureDoesNotMatch » indéchiffrable.
14
+ import { createHash, createHmac } from 'node:crypto';
15
+ const ALGO = 'AWS4-HMAC-SHA256';
16
+ const sha256hex = (data) => createHash('sha256').update(data).digest('hex');
17
+ const hmac = (key, data) => createHmac('sha256', key).update(data, 'utf8').digest();
18
+ /** sha256 hex d'une charge utile (à passer en `payloadHash`). */
19
+ export function hashPayload(body) {
20
+ return sha256hex(body ?? '');
21
+ }
22
+ /**
23
+ * Encodage d'URI d'AWS : RFC 3986 STRICT. `encodeURIComponent` laisse passer `!'()*`,
24
+ * qu'AWS attend encodés — un seul caractère divergent, et la signature ne correspond plus.
25
+ */
26
+ export function uriEncode(str, encodeSlash = true) {
27
+ let out = '';
28
+ for (const ch of str) {
29
+ if (/[A-Za-z0-9_.~-]/.test(ch)) {
30
+ out += ch;
31
+ }
32
+ else if (ch === '/') {
33
+ out += encodeSlash ? '%2F' : '/';
34
+ }
35
+ else {
36
+ out += [...new TextEncoder().encode(ch)]
37
+ .map((b) => `%${b.toString(16).toUpperCase().padStart(2, '0')}`)
38
+ .join('');
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /** Horodatage AWS : `20260713T024500Z` et sa date courte `20260713`. */
44
+ export function amzDate(now) {
45
+ const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
46
+ return { amzDate: iso, dateStamp: iso.slice(0, 8) };
47
+ }
48
+ /** Clé de signature dérivée (date → région → service → aws4_request). */
49
+ export function signingKey(secret, dateStamp, region, service) {
50
+ const kDate = hmac(`AWS4${secret}`, dateStamp);
51
+ const kRegion = hmac(kDate, region);
52
+ const kService = hmac(kRegion, service);
53
+ return hmac(kService, 'aws4_request');
54
+ }
55
+ /** Requête canonique — l'ordre et l'encodage sont NORMATIFS : la moindre variation casse tout. */
56
+ export function canonicalRequest(req) {
57
+ const query = req.query ?? {};
58
+ const canonicalQuery = Object.keys(query)
59
+ .sort()
60
+ .map((k) => `${uriEncode(k)}=${uriEncode(query[k])}`)
61
+ .join('&');
62
+ // En-têtes : noms en minuscules, triés, valeurs « trimmées » et espaces internes compactés.
63
+ const lower = {};
64
+ for (const [k, v] of Object.entries(req.headers)) {
65
+ lower[k.toLowerCase()] = String(v).trim().replace(/\s+/g, ' ');
66
+ }
67
+ const names = Object.keys(lower).sort();
68
+ const canonicalHeaders = names.map((n) => `${n}:${lower[n]}\n`).join('');
69
+ const signedHeaders = names.join(';');
70
+ const canonical = [
71
+ req.method.toUpperCase(),
72
+ req.path,
73
+ canonicalQuery,
74
+ canonicalHeaders,
75
+ signedHeaders,
76
+ req.payloadHash,
77
+ ].join('\n');
78
+ return { canonical, signedHeaders };
79
+ }
80
+ /**
81
+ * Calcule l'en-tête `Authorization` d'une requête SigV4.
82
+ * Retourne aussi les en-têtes à AJOUTER (`x-amz-date`, `x-amz-content-sha256`…).
83
+ */
84
+ export function signRequest(args) {
85
+ const service = args.service ?? 's3';
86
+ const { amzDate: stamp, dateStamp } = amzDate(args.now ?? new Date());
87
+ const headers = {
88
+ ...args.req.headers,
89
+ 'x-amz-date': stamp,
90
+ 'x-amz-content-sha256': args.req.payloadHash,
91
+ };
92
+ if (args.credentials.sessionToken) {
93
+ headers['x-amz-security-token'] = args.credentials.sessionToken;
94
+ }
95
+ const { canonical, signedHeaders } = canonicalRequest({ ...args.req, headers });
96
+ const scope = `${dateStamp}/${args.region}/${service}/aws4_request`;
97
+ const stringToSign = [ALGO, stamp, scope, sha256hex(canonical)].join('\n');
98
+ const key = signingKey(args.credentials.secretAccessKey, dateStamp, args.region, service);
99
+ const signature = createHmac('sha256', key).update(stringToSign, 'utf8').digest('hex');
100
+ const authorization = `${ALGO} Credential=${args.credentials.accessKeyId}/${scope}, ` +
101
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
102
+ return { authorization, headers: { ...headers, authorization } };
103
+ }
104
+ /** Exposé pour les tests (vecteurs officiels AWS). */
105
+ export const _internal = { sha256hex, hmac, ALGO };
package/dist/index.d.ts CHANGED
@@ -24,8 +24,16 @@ export { StorageError, STORAGE_ERROR_HTTP } from './types.js';
24
24
  export type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult, } from './driver.js';
25
25
  export { FilesystemDriver } from './drivers/filesystem.js';
26
26
  export { CachingDriver } from './drivers/caching.js';
27
+ /**
28
+ * Pilote S3 — et tout service S3-compatible (MinIO, Scaleway, Wasabi, Backblaze…).
29
+ * Zéro dépendance : SigV4 est implémenté ici (S3 n'est que du HTTP signé), plutôt que de
30
+ * tirer `@aws-sdk/client-s3`. Solde la promesse « FS / S3 » que `@mostajs/storage` affichait
31
+ * sans la tenir — et la solde AU BON ÉTAGE, pour qu'archive-box et backup en profitent.
32
+ */
33
+ export { S3Driver, type S3DriverConfig } from './drivers/s3.js';
34
+ export { signRequest, hashPayload, uriEncode, type SigV4Credentials } from './drivers/sigv4.js';
27
35
  export declare const moduleInfo: {
28
36
  readonly name: "@mostajs/blob-store";
29
- readonly version: "0.1.0";
37
+ readonly version: "0.2.0";
30
38
  readonly level: "N0";
31
39
  };
package/dist/index.js CHANGED
@@ -22,4 +22,12 @@
22
22
  export { StorageError, STORAGE_ERROR_HTTP } from './types.js';
23
23
  export { FilesystemDriver } from './drivers/filesystem.js';
24
24
  export { CachingDriver } from './drivers/caching.js';
25
- export const moduleInfo = { name: '@mostajs/blob-store', version: '0.1.0', level: 'N0' };
25
+ /**
26
+ * Pilote S3 — et tout service S3-compatible (MinIO, Scaleway, Wasabi, Backblaze…).
27
+ * Zéro dépendance : SigV4 est implémenté ici (S3 n'est que du HTTP signé), plutôt que de
28
+ * tirer `@aws-sdk/client-s3`. Solde la promesse « FS / S3 » que `@mostajs/storage` affichait
29
+ * sans la tenir — et la solde AU BON ÉTAGE, pour qu'archive-box et backup en profitent.
30
+ */
31
+ export { S3Driver } from './drivers/s3.js';
32
+ export { signRequest, hashPayload, uriEncode } from './drivers/sigv4.js';
33
+ export const moduleInfo = { name: '@mostajs/blob-store', version: '0.2.0', level: 'N0' };
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @mostajs/blob-store — 0.1.0
1
+ # @mostajs/blob-store — 0.2.0
2
2
  Auteur : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later · Niveau N0 · ZÉRO dépendance
3
3
 
4
4
  RÔLE
@@ -23,6 +23,11 @@ API
23
23
  | tenant_violation | signature_invalid | signature_expired | driver_error
24
24
  FilesystemDriver new FilesystemDriver({ rootDir }) — refuse '..' (path traversal)
25
25
  CachingDriver décorateur de cache devant un driver d'origine
26
+ S3Driver new S3Driver({ endpoint, region, accessKeyId, secretAccessKey,
27
+ forcePathStyle?, sessionToken?, timeoutMs? })
28
+ S3 + TOUT service S3-compatible (MinIO/Scaleway/Wasabi/Backblaze).
29
+ SigV4 intégré (zéro dépendance — pas d'@aws-sdk). Sous-chemin /drivers/s3.
30
+ signRequest, hashPayload, uriEncode — SigV4 exposée (sous-chemin /sigv4)
26
31
 
27
32
  CE QUI N'EST PAS ICI
28
33
  FileMeta (row File en base, accountId multi-tenant) reste dans @mostajs/storage : c'est une
@@ -32,4 +37,7 @@ CONSOMMATEURS
32
37
  @mostajs/storage (octets + ORM + RBAC) · @mostajs/archive-box · @mostajs/backup
33
38
 
34
39
  TESTS
35
- npm test 10 tests mjs-unit sur un vrai répertoire temporaire (sha256 vérifié indépendamment)
40
+ npm test 28 tests mjs-unit :
41
+ - filesystem : disque réel, sha256 vérifié indépendamment
42
+ - sigv4 : VECTEURS OFFICIELS AWS (get-vanilla) — signature exacte
43
+ - s3 : serveur S3 simulé qui REFUSE toute requête non signée
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mostajs/blob-store",
3
- "version": "0.1.0",
4
- "description": "Ranger des octets — contrat StorageDriver (put/get/delete/exists/stat/list) + pilotes filesystem et caching. Primitive N0, zéro dépendance. Extrait de @mostajs/storage.",
3
+ "version": "0.2.0",
4
+ "description": "Ranger des octets — contrat StorageDriver (put/get/delete/exists/stat/list) + pilotes filesystem, caching et S3 (SigV4 intégré, tout service S3-compatible). Primitive N0, zéro dépendance.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
7
  "type": "module",
@@ -30,6 +30,16 @@
30
30
  "types": "./dist/drivers/caching.d.ts",
31
31
  "import": "./dist/drivers/caching.js",
32
32
  "default": "./dist/drivers/caching.js"
33
+ },
34
+ "./drivers/s3": {
35
+ "types": "./dist/drivers/s3.d.ts",
36
+ "import": "./dist/drivers/s3.js",
37
+ "default": "./dist/drivers/s3.js"
38
+ },
39
+ "./sigv4": {
40
+ "types": "./dist/drivers/sigv4.d.ts",
41
+ "import": "./dist/drivers/sigv4.js",
42
+ "default": "./dist/drivers/sigv4.js"
33
43
  }
34
44
  },
35
45
  "files": [
@@ -58,4 +68,4 @@
58
68
  "@types/node": "^20.19.43",
59
69
  "typescript": "^5.6.0"
60
70
  }
61
- }
71
+ }