@kadi.build/file-sharing 1.0.0 → 1.1.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
@@ -7,8 +7,10 @@ Integrates [`@kadi.build/file-manager`](https://www.npmjs.com/package/@kadi.buil
7
7
 
8
8
  - **HTTP File Server** — Static file serving with directory listing, range requests, CORS, and multi-scheme authentication
9
9
  - **Local S3-Compatible API** — Emulates AWS S3 endpoints locally so you can use `@aws-sdk/client-s3` against your local filesystem
10
+ - **Extensible HTTP Pipeline** — Register custom middleware and routes (e.g. Docker Registry v2 endpoints) alongside built-in file serving
10
11
  - **Tunnel Integration** — Expose your local server publicly via KĀDI (default), ngrok, serveo, localtunnel, or pinggy
11
- - **Authentication** — Basic Auth, Bearer Token, and API Key (header / query param) for HTTP; AWS SigV4 / SigV2 / Bearer for S3
12
+ - **Authentication** — Basic Auth, Bearer Token, and API Key (header / query param) for HTTP; AWS SigV4 / SigV2 / Bearer / Basic for S3
13
+ - **Temporary Credentials** — Generate time-limited S3 credentials for secure sharing
12
14
  - **Secrets Management** — Automatic `.env` loading (walks up parent directories for monorepo support) + environment variables + explicit config
13
15
  - **Download Monitoring** — Track active downloads, progress, speed, and completion statistics
14
16
  - **Graceful Shutdown** — Priority-ordered shutdown with timeout and force-kill
@@ -195,6 +197,7 @@ When custom S3 credentials are set (anything other than the default `minioadmin`
195
197
  | **AWS SigV4** | Standard `Authorization: AWS4-HMAC-SHA256 Credential=<accessKeyId>/...` |
196
198
  | **AWS SigV2** | Legacy `Authorization: AWS <accessKeyId>:signature` |
197
199
  | **Bearer** | Convenience `Authorization: Bearer <accessKeyId>` |
200
+ | **Basic** | Docker-style `Authorization: Basic base64(accessKeyId:secretAccessKey)` |
198
201
  | **Pre-signed URL** | `?X-Amz-Credential=<accessKeyId>/...` query param |
199
202
 
200
203
  > With default credentials (`minioadmin`/`minioadmin`), auth is skipped for backward compatibility unless you set `enforceAuth: true` in the S3 config.
@@ -309,6 +312,12 @@ const server = new FileSharingServer({
309
312
  | `s3:get` | `{bucket, key}` | S3 GetObject |
310
313
  | `s3:put` | `{bucket, key}` | S3 PutObject |
311
314
  | `s3:delete` | `{bucket, key}` | S3 DeleteObject |
315
+ | `bucket:removed` | `{bucket}` | Bucket programmatically removed |
316
+ | `credentials:generated` | `{accessKey, expiresAt}` | Temporary credentials created |
317
+ | `middleware:added` | `{name, priority}` | HTTP middleware registered |
318
+ | `middleware:removed` | `{name}` | HTTP middleware removed |
319
+ | `route:added` | `{method, path, priority}` | Custom HTTP route registered |
320
+ | `route:removed` | `{method, path}` | Custom HTTP route removed |
312
321
  | `tunnel:created` | `TunnelInfo` | Tunnel established |
313
322
  | `tunnel:closed` | — | Tunnel shut down |
314
323
  | `tunnel:error` | `Error` | Tunnel failed (non-fatal) |
@@ -317,7 +326,7 @@ const server = new FileSharingServer({
317
326
 
318
327
  ### HttpServerProvider
319
328
 
320
- Low-level HTTP file server with authentication.
329
+ Low-level HTTP file server with authentication and extensibility.
321
330
 
322
331
  ```javascript
323
332
  import { HttpServerProvider } from '@kadi.build/file-sharing/http';
@@ -333,6 +342,67 @@ const httpServer = new HttpServerProvider({
333
342
  await httpServer.start();
334
343
  ```
335
344
 
345
+ #### Middleware (Extensibility)
346
+
347
+ Register middleware that runs **before** built-in auth and static file handling. Higher priority runs first.
348
+
349
+ ```javascript
350
+ // Add authentication middleware (priority 10 = runs before default handlers)
351
+ httpServer.addMiddleware('dockerAuth', (req, res, next) => {
352
+ if (req.url.startsWith('/v2')) {
353
+ const auth = req.headers.authorization;
354
+ if (!auth || !isValidToken(auth)) {
355
+ res.writeHead(401);
356
+ res.end(JSON.stringify({ errors: [{ code: 'UNAUTHORIZED' }] }));
357
+ return;
358
+ }
359
+ req.user = { token: auth };
360
+ }
361
+ next();
362
+ }, { priority: 10 });
363
+
364
+ // List registered middleware
365
+ httpServer.getMiddleware(); // [{ name: 'dockerAuth', priority: 10 }]
366
+
367
+ // Remove middleware
368
+ httpServer.removeMiddleware('dockerAuth');
369
+ ```
370
+
371
+ #### Custom Routes (Extensibility)
372
+
373
+ Register custom route handlers with Express-style `:param` path patterns. Custom routes match **before** built-in static file serving.
374
+
375
+ ```javascript
376
+ // Docker Registry v2 ping
377
+ httpServer.addCustomRoute('GET', '/v2/', (req, res) => {
378
+ res.writeHead(200, { 'Content-Type': 'application/json' });
379
+ res.end('{}');
380
+ });
381
+
382
+ // Route with path parameters — req.params is populated automatically
383
+ httpServer.addCustomRoute('GET', '/v2/:name/manifests/:reference', (req, res) => {
384
+ console.log(req.params); // { name: 'myapp', reference: 'latest' }
385
+ res.writeHead(200, { 'Content-Type': 'application/json' });
386
+ res.end(JSON.stringify(getManifest(req.params.name, req.params.reference)));
387
+ });
388
+
389
+ // HEAD method support
390
+ httpServer.addCustomRoute('HEAD', '/v2/:name/blobs/:digest', (req, res) => {
391
+ res.writeHead(200, { 'Docker-Content-Digest': req.params.digest });
392
+ res.end();
393
+ });
394
+
395
+ // List / remove routes
396
+ httpServer.getCustomRoutes(); // [{ method, path, priority }]
397
+ httpServer.removeCustomRoute('GET', '/v2/'); // true if found
398
+ ```
399
+
400
+ #### Request Pipeline Order
401
+
402
+ ```
403
+ Request → CORS → Middleware Chain → Custom Routes → Built-in Auth → Static Files
404
+ ```
405
+
336
406
  ### S3Server
337
407
 
338
408
  Local S3-compatible API server. Files are stored **on disk**, not on AWS.
@@ -350,7 +420,8 @@ const s3 = new S3Server({
350
420
  enforceAuth: true // validate even with default creds
351
421
  });
352
422
 
353
- await s3.start();
423
+ const info = await s3.start();
424
+ console.log(info.serverId); // 'kadi-s3-9000'
354
425
  ```
355
426
 
356
427
  **Supported S3 Operations:**
@@ -363,6 +434,48 @@ await s3.start();
363
434
  - `HeadObject`
364
435
  - `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`
365
436
 
437
+ #### Programmatic Authentication Validation
438
+
439
+ Validate incoming requests against configured credentials. Returns a structured result — useful for layering custom auth (e.g. Docker Registry) on top of S3.
440
+
441
+ ```javascript
442
+ const result = s3.validateAuthentication(req);
443
+ // Success: { success: true, user: { accessKey: 'AKIA...' } }
444
+ // Failure: { success: false, error: 'Invalid credentials' }
445
+ ```
446
+
447
+ Supports AWS SigV4, SigV2, Bearer, Basic Auth (Docker clients), and pre-signed URL query params.
448
+
449
+ #### Temporary Credentials
450
+
451
+ Generate time-limited credentials for secure sharing:
452
+
453
+ ```javascript
454
+ const creds = s3.generateTemporaryCredentials({ ttl: 3600 }); // 1 hour
455
+ console.log(creds.accessKey); // 'AKIA...' (random)
456
+ console.log(creds.secretKey); // random hex string
457
+ console.log(creds.expiresAt); // '2026-02-16T...' (ISO string)
458
+ console.log(creds.expiry); // Date object (backward compat)
459
+
460
+ // Temp credentials are automatically validated by _checkS3Auth and validateAuthentication
461
+ ```
462
+
463
+ #### Programmatic Bucket Removal
464
+
465
+ Force-delete a bucket directory and all its contents (for cleanup):
466
+
467
+ ```javascript
468
+ await s3.removeBucket('container-id-123');
469
+ // Emits 'bucket:removed' event. Idempotent (no error if bucket doesn't exist).
470
+ ```
471
+
472
+ #### Properties
473
+
474
+ | Property | Type | Description |
475
+ |----------|------|-------------|
476
+ | `serverId` | `string` | Stable server identifier (e.g. `kadi-s3-9000`) |
477
+ | `isRunning` | `boolean` | Whether the server is running |
478
+
366
479
  ### createQuickShare
367
480
 
368
481
  One-liner to share a directory with optional tunnel and auth.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kadi.build/file-sharing",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "File sharing service with tunneling and local S3-compatible interface",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -29,7 +29,8 @@
29
29
  "test:integration": "node tests/test-file-sharing.js",
30
30
  "test:monitor": "node tests/test-download-monitor.js",
31
31
  "test:shutdown": "node tests/test-shutdown-manager.js",
32
- "test:streaming": "node tests/test-streaming.js"
32
+ "test:streaming": "node tests/test-streaming.js",
33
+ "test:extensibility": "node tests/test-extensibility.js"
33
34
  },
34
35
  "keywords": [
35
36
  "file-sharing",
@@ -45,6 +45,10 @@ export class HttpServerProvider extends EventEmitter {
45
45
  this.startTime = null;
46
46
  this.requestCount = 0;
47
47
  this.errorCount = 0;
48
+
49
+ // Extensibility: middleware chain and custom routes (sorted by priority desc)
50
+ this._middlewares = [];
51
+ this._customRoutes = [];
48
52
  }
49
53
 
50
54
  /**
@@ -143,6 +147,243 @@ export class HttpServerProvider extends EventEmitter {
143
147
  }
144
148
  }
145
149
 
150
+ // ============================================================================
151
+ // EXTENSIBILITY — MIDDLEWARE & CUSTOM ROUTES
152
+ // ============================================================================
153
+
154
+ /**
155
+ * Register a named middleware function into the request pipeline.
156
+ * Middleware runs BEFORE built-in auth and static-file handling.
157
+ *
158
+ * @param {string} name - Unique middleware identifier (e.g. 'dockerAuth')
159
+ * @param {Function} handler - (req, res, next) => void
160
+ * @param {Object} [opts]
161
+ * @param {number} [opts.priority=0] - Higher runs first
162
+ */
163
+ addMiddleware(name, handler, opts = {}) {
164
+ if (typeof name !== 'string' || !name) {
165
+ throw new Error('Middleware name must be a non-empty string');
166
+ }
167
+ if (typeof handler !== 'function') {
168
+ throw new Error('Middleware handler must be a function');
169
+ }
170
+
171
+ // Remove existing middleware with the same name (upsert semantics)
172
+ this._middlewares = this._middlewares.filter(m => m.name !== name);
173
+
174
+ const priority = opts.priority ?? 0;
175
+ this._middlewares.push({ name, handler, priority });
176
+
177
+ // Sort descending by priority (higher priority runs first)
178
+ this._middlewares.sort((a, b) => b.priority - a.priority);
179
+
180
+ this.emit('middleware:added', { name, priority });
181
+ }
182
+
183
+ /**
184
+ * Remove a named middleware.
185
+ *
186
+ * @param {string} name - Middleware identifier to remove
187
+ * @returns {boolean} true if middleware was found and removed
188
+ */
189
+ removeMiddleware(name) {
190
+ const before = this._middlewares.length;
191
+ this._middlewares = this._middlewares.filter(m => m.name !== name);
192
+ const removed = this._middlewares.length < before;
193
+ if (removed) {
194
+ this.emit('middleware:removed', { name });
195
+ }
196
+ return removed;
197
+ }
198
+
199
+ /**
200
+ * Get the list of registered middleware names (in execution order).
201
+ * @returns {string[]}
202
+ */
203
+ getMiddleware() {
204
+ return this._middlewares.map(m => ({ name: m.name, priority: m.priority }));
205
+ }
206
+
207
+ /**
208
+ * Register a custom route handler.
209
+ * Custom routes are matched BEFORE built-in static-file/upload handling.
210
+ *
211
+ * @param {string} method - HTTP method ('GET', 'HEAD', 'POST', etc.)
212
+ * @param {string} urlPath - URL path or pattern. Supports Express-style :param segments.
213
+ * @param {Function} handler - (req, res) => void. req.params populated from path pattern.
214
+ * @param {Object} [opts]
215
+ * @param {number} [opts.priority=0] - Higher = matched first (before S3/static routes)
216
+ */
217
+ addCustomRoute(method, urlPath, handler, opts = {}) {
218
+ if (typeof method !== 'string' || !method) {
219
+ throw new Error('Route method must be a non-empty string');
220
+ }
221
+ if (typeof urlPath !== 'string') {
222
+ throw new Error('Route path must be a string');
223
+ }
224
+ if (typeof handler !== 'function') {
225
+ throw new Error('Route handler must be a function');
226
+ }
227
+
228
+ const normalizedMethod = method.toUpperCase();
229
+ const priority = opts.priority ?? 0;
230
+ const compiled = this._compilePath(urlPath);
231
+
232
+ // Remove existing route with same method + path (upsert)
233
+ this._customRoutes = this._customRoutes.filter(
234
+ r => !(r.method === normalizedMethod && r.path === urlPath)
235
+ );
236
+
237
+ this._customRoutes.push({
238
+ method: normalizedMethod,
239
+ path: urlPath,
240
+ handler,
241
+ priority,
242
+ ...compiled
243
+ });
244
+
245
+ // Sort descending by priority
246
+ this._customRoutes.sort((a, b) => b.priority - a.priority);
247
+
248
+ this.emit('route:added', { method: normalizedMethod, path: urlPath, priority });
249
+ }
250
+
251
+ /**
252
+ * Remove a custom route.
253
+ *
254
+ * @param {string} method - HTTP method
255
+ * @param {string} urlPath - URL path pattern
256
+ * @returns {boolean} true if route was found and removed
257
+ */
258
+ removeCustomRoute(method, urlPath) {
259
+ const normalizedMethod = method.toUpperCase();
260
+ const before = this._customRoutes.length;
261
+ this._customRoutes = this._customRoutes.filter(
262
+ r => !(r.method === normalizedMethod && r.path === urlPath)
263
+ );
264
+ const removed = this._customRoutes.length < before;
265
+ if (removed) {
266
+ this.emit('route:removed', { method: normalizedMethod, path: urlPath });
267
+ }
268
+ return removed;
269
+ }
270
+
271
+ /**
272
+ * Get the list of registered custom routes.
273
+ * @returns {Array<{ method: string, path: string, priority: number }>}
274
+ */
275
+ getCustomRoutes() {
276
+ return this._customRoutes.map(r => ({ method: r.method, path: r.path, priority: r.priority }));
277
+ }
278
+
279
+ /**
280
+ * Compile an Express-style path pattern into a regex and param name list.
281
+ *
282
+ * Supports:
283
+ * - Literal paths: `/v2/`
284
+ * - Named params: `/v2/:name/manifests/:reference`
285
+ *
286
+ * @private
287
+ * @param {string} pattern - URL path pattern
288
+ * @returns {{ regex: RegExp, paramNames: string[] }}
289
+ */
290
+ _compilePath(pattern) {
291
+ const paramNames = [];
292
+ // Escape special regex chars except : (used for params) and /
293
+ const regexStr = pattern
294
+ .replace(/([.+?^${}()|[\]\\])/g, '\\$1')
295
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
296
+ paramNames.push(paramName);
297
+ return '([^/]+)';
298
+ });
299
+
300
+ // Exact match — anchor both ends
301
+ const regex = new RegExp(`^${regexStr}$`);
302
+ return { regex, paramNames };
303
+ }
304
+
305
+ /**
306
+ * Run the middleware chain. Returns true if a middleware ended the response.
307
+ * @private
308
+ * @param {import('http').IncomingMessage} req
309
+ * @param {import('http').ServerResponse} res
310
+ * @returns {Promise<boolean>} true if response was ended by middleware
311
+ */
312
+ async _runMiddlewareChain(req, res) {
313
+ const middlewares = this._middlewares;
314
+ if (middlewares.length === 0) return false;
315
+
316
+ let index = 0;
317
+ let responseEnded = false;
318
+
319
+ const runNext = () => {
320
+ return new Promise((resolve, reject) => {
321
+ if (responseEnded || res.writableEnded) {
322
+ responseEnded = true;
323
+ return resolve();
324
+ }
325
+
326
+ if (index >= middlewares.length) {
327
+ return resolve(); // All middleware passed through
328
+ }
329
+
330
+ const mw = middlewares[index++];
331
+ try {
332
+ const result = mw.handler(req, res, (err) => {
333
+ if (err) {
334
+ return reject(err);
335
+ }
336
+ if (res.writableEnded) {
337
+ responseEnded = true;
338
+ return resolve();
339
+ }
340
+ runNext().then(resolve).catch(reject);
341
+ });
342
+
343
+ // Support async middleware that returns a promise
344
+ if (result && typeof result.then === 'function') {
345
+ result.catch(reject);
346
+ }
347
+ } catch (err) {
348
+ reject(err);
349
+ }
350
+ });
351
+ };
352
+
353
+ await runNext();
354
+ return responseEnded || res.writableEnded;
355
+ }
356
+
357
+ /**
358
+ * Try to match an incoming request against registered custom routes.
359
+ * @private
360
+ * @param {string} method - HTTP method
361
+ * @param {string} pathname - Decoded URL pathname
362
+ * @returns {{ route: object, params: object } | null}
363
+ */
364
+ _matchCustomRoute(method, pathname) {
365
+ const upperMethod = method.toUpperCase();
366
+
367
+ for (const route of this._customRoutes) {
368
+ if (route.method !== upperMethod) continue;
369
+
370
+ const match = route.regex.exec(pathname);
371
+ if (match) {
372
+ const params = {};
373
+ route.paramNames.forEach((name, i) => {
374
+ params[name] = decodeURIComponent(match[i + 1]);
375
+ });
376
+ return { route, params };
377
+ }
378
+ }
379
+
380
+ return null;
381
+ }
382
+
383
+ // ============================================================================
384
+ // REQUEST HANDLING
385
+ // ============================================================================
386
+
146
387
  /**
147
388
  * Create the main request handler
148
389
  * @private
@@ -163,7 +404,29 @@ export class HttpServerProvider extends EventEmitter {
163
404
  }
164
405
  }
165
406
 
166
- // Authentication
407
+ // ── Middleware chain (Gap 1) ──────────────────────────────────
408
+ // Run registered middleware before auth and routing.
409
+ // If any middleware ends the response, stop processing.
410
+ if (this._middlewares.length > 0) {
411
+ const handled = await this._runMiddlewareChain(req, res);
412
+ if (handled) return;
413
+ }
414
+
415
+ // ── Custom routes (Gap 2) ────────────────────────────────────
416
+ // Check registered custom routes before built-in handling.
417
+ const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
418
+ const urlPath = decodeURIComponent(urlObj.pathname);
419
+
420
+ if (this._customRoutes.length > 0) {
421
+ const routeMatch = this._matchCustomRoute(req.method, urlPath);
422
+ if (routeMatch) {
423
+ req.params = routeMatch.params;
424
+ await routeMatch.route.handler(req, res);
425
+ return;
426
+ }
427
+ }
428
+
429
+ // ── Built-in authentication ──────────────────────────────────
167
430
  if (this.config.auth) {
168
431
  if (!this._checkAuth(req)) {
169
432
  // Set appropriate WWW-Authenticate based on configured scheme
@@ -179,9 +442,7 @@ export class HttpServerProvider extends EventEmitter {
179
442
  }
180
443
  }
181
444
 
182
- // Route request
183
- const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
184
- const urlPath = decodeURIComponent(urlObj.pathname);
445
+ // ── Static file handling ─────────────────────────────────────
185
446
  const filePath = path.join(this.config.staticDir, urlPath);
186
447
 
187
448
  // Security: prevent directory traversal
package/src/S3Server.js CHANGED
@@ -48,6 +48,20 @@ export class S3Server extends EventEmitter {
48
48
  this.isRunning = false;
49
49
  this.multipartUploads = new Map();
50
50
  this.requestCount = 0;
51
+
52
+ // Temporary credentials store (Gap 7) — Map<accessKey, { secretKey, expiresAt }>
53
+ this._temporaryCredentials = new Map();
54
+
55
+ // Stable server identifier (Gap 5)
56
+ this._serverId = `kadi-s3-${this.config.port}`;
57
+ }
58
+
59
+ /**
60
+ * Stable server identifier.
61
+ * @type {string}
62
+ */
63
+ get serverId() {
64
+ return this._serverId;
51
65
  }
52
66
 
53
67
  /**
@@ -102,10 +116,13 @@ export class S3Server extends EventEmitter {
102
116
  const addr = this.server.address();
103
117
  this.config.port = addr.port;
104
118
  this.isRunning = true;
119
+ this._serverId = `kadi-s3-${addr.port}`;
105
120
 
106
121
  const info = {
107
122
  port: addr.port,
108
- endpoint: `http://${this.config.host}:${addr.port}`
123
+ host: this.config.host,
124
+ endpoint: `http://${this.config.host}:${addr.port}`,
125
+ serverId: this._serverId
109
126
  };
110
127
 
111
128
  this.emit('started', info);
@@ -893,10 +910,170 @@ export class S3Server extends EventEmitter {
893
910
  res.end(xml);
894
911
  }
895
912
 
913
+ // ============================================================================
914
+ // PUBLIC EXTENSIBILITY APIs
915
+ // ============================================================================
916
+
917
+ /**
918
+ * Validate an incoming request against the server's configured credentials.
919
+ * Returns a structured result object (unlike the private _checkS3Auth which
920
+ * returns a plain boolean).
921
+ *
922
+ * This is the public API that consumers like container-registry-ability use
923
+ * to validate Docker Registry API requests carry valid S3 credentials.
924
+ *
925
+ * @param {import('http').IncomingMessage} req
926
+ * @returns {{ success: boolean, user?: { accessKey: string }, error?: string }}
927
+ */
928
+ validateAuthentication(req) {
929
+ // Attempt to extract the access key from the request for the user object
930
+ const accessKey = this._extractAccessKey(req);
931
+ const isValid = this._checkS3Auth(req);
932
+
933
+ if (isValid) {
934
+ return {
935
+ success: true,
936
+ user: { accessKey: accessKey || this.config.accessKeyId }
937
+ };
938
+ }
939
+
940
+ return {
941
+ success: false,
942
+ error: 'Invalid credentials'
943
+ };
944
+ }
945
+
946
+ /**
947
+ * Programmatically remove a bucket (delete its directory from the S3 root).
948
+ * Unlike the S3 API DeleteBucket handler, this force-deletes even non-empty
949
+ * directories — intended for cleanup when containers are removed.
950
+ *
951
+ * @param {string} bucketName - The bucket/directory name to remove
952
+ * @returns {Promise<void>}
953
+ */
954
+ async removeBucket(bucketName) {
955
+ if (!bucketName || typeof bucketName !== 'string') {
956
+ throw new Error('bucketName must be a non-empty string');
957
+ }
958
+
959
+ // Security: prevent directory traversal
960
+ if (bucketName.includes('..') || bucketName.includes('/') || bucketName.includes('\\')) {
961
+ throw new Error('Invalid bucket name — must not contain path separators or ".."');
962
+ }
963
+
964
+ const bucketDir = path.join(this.config.rootDir, bucketName);
965
+
966
+ // Ensure the resolved path is within rootDir
967
+ const resolvedRoot = path.resolve(this.config.rootDir);
968
+ const resolvedBucket = path.resolve(bucketDir);
969
+ if (!resolvedBucket.startsWith(resolvedRoot)) {
970
+ throw new Error('Invalid bucket name — path traversal detected');
971
+ }
972
+
973
+ try {
974
+ await fs.rm(bucketDir, { recursive: true, force: true });
975
+ this.emit('bucket:removed', { bucket: bucketName });
976
+ } catch (error) {
977
+ if (error.code !== 'ENOENT') {
978
+ throw error;
979
+ }
980
+ // Bucket doesn't exist — treat as success (idempotent)
981
+ }
982
+ }
983
+
984
+ /**
985
+ * Generate temporary S3 credentials with an expiry.
986
+ *
987
+ * Returns both `expiresAt` (ISO string) and `expiry` (Date object) for
988
+ * backward compatibility with consumers expecting the old property name.
989
+ *
990
+ * @param {Object} [opts]
991
+ * @param {number} [opts.ttl=3600] - Time-to-live in seconds (default: 1 hour)
992
+ * @returns {{ accessKey: string, secretKey: string, expiresAt: string, expiry: Date }}
993
+ */
994
+ generateTemporaryCredentials(opts = {}) {
995
+ const ttl = opts.ttl ?? 3600;
996
+ const accessKey = `AKIA${crypto.randomBytes(8).toString('hex').toUpperCase()}`;
997
+ const secretKey = crypto.randomBytes(20).toString('hex');
998
+ const expiryDate = new Date(Date.now() + ttl * 1000);
999
+
1000
+ // Store for validation
1001
+ this._temporaryCredentials.set(accessKey, {
1002
+ secretKey,
1003
+ expiresAt: expiryDate.getTime()
1004
+ });
1005
+
1006
+ // Schedule cleanup
1007
+ setTimeout(() => {
1008
+ this._temporaryCredentials.delete(accessKey);
1009
+ }, ttl * 1000).unref();
1010
+
1011
+ this.emit('credentials:generated', { accessKey, expiresAt: expiryDate.toISOString() });
1012
+
1013
+ return {
1014
+ accessKey,
1015
+ secretKey,
1016
+ expiresAt: expiryDate.toISOString(),
1017
+ expiry: expiryDate // backward compat alias
1018
+ };
1019
+ }
1020
+
896
1021
  // ============================================================================
897
1022
  // AUTHENTICATION
898
1023
  // ============================================================================
899
1024
 
1025
+ /**
1026
+ * Extract the access key from an incoming request, if present.
1027
+ * Used by validateAuthentication() to populate the user object.
1028
+ *
1029
+ * @private
1030
+ * @param {import('http').IncomingMessage} req
1031
+ * @returns {string|null}
1032
+ */
1033
+ _extractAccessKey(req) {
1034
+ const authHeader = req.headers.authorization || '';
1035
+
1036
+ // AWS Signature V4
1037
+ if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
1038
+ const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
1039
+ if (credMatch) return credMatch[1];
1040
+ }
1041
+
1042
+ // AWS Signature V2
1043
+ if (authHeader.startsWith('AWS ')) {
1044
+ return authHeader.slice(4).split(':')[0] || null;
1045
+ }
1046
+
1047
+ // Bearer
1048
+ if (authHeader.startsWith('Bearer ')) {
1049
+ return authHeader.slice(7) || null;
1050
+ }
1051
+
1052
+ // Basic auth (Docker clients)
1053
+ if (authHeader.startsWith('Basic ')) {
1054
+ try {
1055
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
1056
+ const [user] = decoded.split(':');
1057
+ return user || null;
1058
+ } catch {
1059
+ return null;
1060
+ }
1061
+ }
1062
+
1063
+ // Query params
1064
+ try {
1065
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1066
+ const amzCred = url.searchParams.get('X-Amz-Credential');
1067
+ if (amzCred) return amzCred.split('/')[0];
1068
+ const awsKey = url.searchParams.get('AWSAccessKeyId');
1069
+ if (awsKey) return awsKey;
1070
+ } catch {
1071
+ // ignore
1072
+ }
1073
+
1074
+ return null;
1075
+ }
1076
+
900
1077
  /**
901
1078
  * Validate S3 request authentication.
902
1079
  *
@@ -907,17 +1084,13 @@ export class S3Server extends EventEmitter {
907
1084
  * - AWS Signature V4: `AWS4-HMAC-SHA256 Credential=<accessKeyId>/…`
908
1085
  * - AWS Signature V2: `AWS <accessKeyId>:…`
909
1086
  * - Simple Bearer: `Bearer <accessKeyId>` (non-standard convenience)
1087
+ * - Basic auth: `Basic base64(accessKey:secretKey)` (Docker clients)
910
1088
  *
911
1089
  * 2. Query-string pre-signed URL parameters
912
1090
  * - V4: `?X-Amz-Credential=<accessKeyId>/…`
913
1091
  * - V2: `?AWSAccessKeyId=<accessKeyId>`
914
1092
  *
915
- * Note: This is intentionally *identity-based* (checks access key) rather
916
- * than a full HMAC signature check. A full SigV4 implementation would
917
- * require reconstructing the canonical request exactly as the SDK built it,
918
- * which is fragile and unnecessary for a local dev server. The goal is to
919
- * prevent accidental unauthenticated access when credentials are configured,
920
- * not to replicate AWS IAM.
1093
+ * Also checks temporary credentials generated via generateTemporaryCredentials().
921
1094
  *
922
1095
  * @private
923
1096
  * @param {import('http').IncomingMessage} req
@@ -939,23 +1112,41 @@ export class S3Server extends EventEmitter {
939
1112
  // AWS Signature V4: "AWS4-HMAC-SHA256 Credential=<accessKeyId>/date/region/s3/aws4_request, …"
940
1113
  if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
941
1114
  const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
942
- if (credMatch && credMatch[1] === accessKeyId) {
943
- return true;
1115
+ if (credMatch) {
1116
+ if (credMatch[1] === accessKeyId) return true;
1117
+ // Check temporary credentials
1118
+ if (this._checkTempCredentials(credMatch[1])) return true;
944
1119
  }
945
1120
  }
946
1121
 
947
1122
  // AWS Signature V2: "AWS <accessKeyId>:<signature>"
948
1123
  if (authHeader.startsWith('AWS ')) {
949
1124
  const parts = authHeader.slice(4).split(':');
950
- if (parts[0] === accessKeyId) {
951
- return true;
952
- }
1125
+ if (parts[0] === accessKeyId) return true;
1126
+ if (this._checkTempCredentials(parts[0])) return true;
953
1127
  }
954
1128
 
955
1129
  // Simple Bearer (non-standard convenience for agents/scripts)
956
1130
  if (authHeader.startsWith('Bearer ')) {
957
- if (authHeader.slice(7) === accessKeyId) {
958
- return true;
1131
+ const token = authHeader.slice(7);
1132
+ if (token === accessKeyId) return true;
1133
+ if (this._checkTempCredentials(token)) return true;
1134
+ }
1135
+
1136
+ // Basic auth (Docker clients send Basic base64(accessKey:secretKey))
1137
+ if (authHeader.startsWith('Basic ')) {
1138
+ try {
1139
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
1140
+ const [user, pass] = decoded.split(':');
1141
+ // Check against primary credentials
1142
+ if (user === accessKeyId && pass === secretAccessKey) return true;
1143
+ // Check against temporary credentials
1144
+ const tempCred = this._temporaryCredentials.get(user);
1145
+ if (tempCred && tempCred.expiresAt > Date.now() && pass === tempCred.secretKey) {
1146
+ return true;
1147
+ }
1148
+ } catch {
1149
+ // Malformed Basic auth — fall through
959
1150
  }
960
1151
  }
961
1152
 
@@ -965,13 +1156,17 @@ export class S3Server extends EventEmitter {
965
1156
 
966
1157
  // V4 pre-signed: ?X-Amz-Credential=<accessKeyId>/…
967
1158
  const amzCred = url.searchParams.get('X-Amz-Credential');
968
- if (amzCred && amzCred.startsWith(accessKeyId + '/')) {
969
- return true;
1159
+ if (amzCred) {
1160
+ const credKey = amzCred.split('/')[0];
1161
+ if (credKey === accessKeyId) return true;
1162
+ if (this._checkTempCredentials(credKey)) return true;
970
1163
  }
971
1164
 
972
1165
  // V2 pre-signed: ?AWSAccessKeyId=<accessKeyId>
973
- if (url.searchParams.get('AWSAccessKeyId') === accessKeyId) {
974
- return true;
1166
+ const awsKey = url.searchParams.get('AWSAccessKeyId');
1167
+ if (awsKey) {
1168
+ if (awsKey === accessKeyId) return true;
1169
+ if (this._checkTempCredentials(awsKey)) return true;
975
1170
  }
976
1171
  } catch {
977
1172
  // Malformed URL — deny
@@ -979,6 +1174,26 @@ export class S3Server extends EventEmitter {
979
1174
 
980
1175
  return false;
981
1176
  }
1177
+
1178
+ /**
1179
+ * Check if an access key matches a valid, non-expired temporary credential.
1180
+ *
1181
+ * @private
1182
+ * @param {string} accessKey
1183
+ * @returns {boolean}
1184
+ */
1185
+ _checkTempCredentials(accessKey) {
1186
+ const cred = this._temporaryCredentials.get(accessKey);
1187
+ if (!cred) return false;
1188
+
1189
+ // Expired?
1190
+ if (cred.expiresAt <= Date.now()) {
1191
+ this._temporaryCredentials.delete(accessKey);
1192
+ return false;
1193
+ }
1194
+
1195
+ return true;
1196
+ }
982
1197
  }
983
1198
 
984
1199
  export default S3Server;