@kadi.build/file-sharing 1.0.0 → 1.1.1

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.1",
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",
@@ -123,6 +123,16 @@ function _filterDefined(obj) {
123
123
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
124
124
  }
125
125
 
126
+ /**
127
+ * Convert a bind address (like `0.0.0.0` or `::`) into a client-usable address.
128
+ * Bind-all addresses are not connectable by clients; replace them with `127.0.0.1`.
129
+ * @param {string} host
130
+ * @returns {string}
131
+ */
132
+ function _clientHost(host) {
133
+ return (host === '0.0.0.0' || host === '::' || host === '[::]') ? '127.0.0.1' : host;
134
+ }
135
+
126
136
  export class FileSharingServer extends EventEmitter {
127
137
  constructor(config = {}) {
128
138
  super();
@@ -169,6 +179,24 @@ export class FileSharingServer extends EventEmitter {
169
179
  };
170
180
  }
171
181
 
182
+ // ----------------------------------------------------------------
183
+ // Port collision detection: when S3 is enabled and both ports match,
184
+ // auto-assign s3Port to 0 (OS picks a free port) to avoid EADDRINUSE.
185
+ // ----------------------------------------------------------------
186
+ if (this.config.enableS3 && this.config.port === this.config.s3Port && this.config.port !== 0) {
187
+ const userExplicitlySetS3Port = config.s3Port !== undefined;
188
+ if (userExplicitlySetS3Port) {
189
+ throw new Error(
190
+ `Port collision: port and s3Port are both set to ${this.config.port}. ` +
191
+ `The HTTP server and S3 server cannot bind the same port. ` +
192
+ `Please specify a different s3Port (or use s3Port: 0 for auto-assign).`
193
+ );
194
+ }
195
+ // User only set `port` (not s3Port) and it happens to match the s3Port default.
196
+ // Auto-resolve by letting the OS pick a free port for S3.
197
+ this.config.s3Port = 0;
198
+ }
199
+
172
200
  // ----------------------------------------------------------------
173
201
  // Load secrets from env vars / .env (constructor config takes priority)
174
202
  // ----------------------------------------------------------------
@@ -412,12 +440,13 @@ export class FileSharingServer extends EventEmitter {
412
440
  * @returns {object} Server information
413
441
  */
414
442
  getInfo() {
443
+ const host = _clientHost(this.config.host);
415
444
  return {
416
445
  isRunning: this.isRunning,
417
- localUrl: `http://${this.config.host}:${this.config.port}`,
446
+ localUrl: `http://${host}:${this.config.port}`,
418
447
  publicUrl: this.tunnel?.publicUrl || null,
419
448
  s3Endpoint: this.s3Server?.isRunning
420
- ? `http://${this.config.host}:${this.config.s3Port}`
449
+ ? `http://${host}:${this.s3Server.config.port}`
421
450
  : null,
422
451
  staticDir: this.config.staticDir,
423
452
  stats: this.downloadMonitor.getStats(),
@@ -488,7 +517,7 @@ export class FileSharingServer extends EventEmitter {
488
517
  /** @type {string|null} */
489
518
  get s3Endpoint() {
490
519
  return this.s3Server?.isRunning
491
- ? `http://${this.config.host}:${this.config.s3Port}`
520
+ ? `http://${_clientHost(this.config.host)}:${this.s3Server.config.port}`
492
521
  : null;
493
522
  }
494
523
 
@@ -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
  /**
@@ -84,10 +88,14 @@ export class HttpServerProvider extends EventEmitter {
84
88
  this.startTime = new Date();
85
89
 
86
90
  const protocol = this.config.ssl ? 'https' : 'http';
91
+ // Use a client-connectable host in the URL (0.0.0.0 is a bind address, not routable)
92
+ const clientHost = (this.config.host === '0.0.0.0' || this.config.host === '::' || this.config.host === '[::]')
93
+ ? '127.0.0.1'
94
+ : this.config.host;
87
95
  const info = {
88
96
  port: addr.port,
89
97
  host: this.config.host,
90
- url: `${protocol}://${this.config.host}:${addr.port}`
98
+ url: `${protocol}://${clientHost}:${addr.port}`
91
99
  };
92
100
 
93
101
  this.emit('started', info);
@@ -143,6 +151,243 @@ export class HttpServerProvider extends EventEmitter {
143
151
  }
144
152
  }
145
153
 
154
+ // ============================================================================
155
+ // EXTENSIBILITY — MIDDLEWARE & CUSTOM ROUTES
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Register a named middleware function into the request pipeline.
160
+ * Middleware runs BEFORE built-in auth and static-file handling.
161
+ *
162
+ * @param {string} name - Unique middleware identifier (e.g. 'dockerAuth')
163
+ * @param {Function} handler - (req, res, next) => void
164
+ * @param {Object} [opts]
165
+ * @param {number} [opts.priority=0] - Higher runs first
166
+ */
167
+ addMiddleware(name, handler, opts = {}) {
168
+ if (typeof name !== 'string' || !name) {
169
+ throw new Error('Middleware name must be a non-empty string');
170
+ }
171
+ if (typeof handler !== 'function') {
172
+ throw new Error('Middleware handler must be a function');
173
+ }
174
+
175
+ // Remove existing middleware with the same name (upsert semantics)
176
+ this._middlewares = this._middlewares.filter(m => m.name !== name);
177
+
178
+ const priority = opts.priority ?? 0;
179
+ this._middlewares.push({ name, handler, priority });
180
+
181
+ // Sort descending by priority (higher priority runs first)
182
+ this._middlewares.sort((a, b) => b.priority - a.priority);
183
+
184
+ this.emit('middleware:added', { name, priority });
185
+ }
186
+
187
+ /**
188
+ * Remove a named middleware.
189
+ *
190
+ * @param {string} name - Middleware identifier to remove
191
+ * @returns {boolean} true if middleware was found and removed
192
+ */
193
+ removeMiddleware(name) {
194
+ const before = this._middlewares.length;
195
+ this._middlewares = this._middlewares.filter(m => m.name !== name);
196
+ const removed = this._middlewares.length < before;
197
+ if (removed) {
198
+ this.emit('middleware:removed', { name });
199
+ }
200
+ return removed;
201
+ }
202
+
203
+ /**
204
+ * Get the list of registered middleware names (in execution order).
205
+ * @returns {string[]}
206
+ */
207
+ getMiddleware() {
208
+ return this._middlewares.map(m => ({ name: m.name, priority: m.priority }));
209
+ }
210
+
211
+ /**
212
+ * Register a custom route handler.
213
+ * Custom routes are matched BEFORE built-in static-file/upload handling.
214
+ *
215
+ * @param {string} method - HTTP method ('GET', 'HEAD', 'POST', etc.)
216
+ * @param {string} urlPath - URL path or pattern. Supports Express-style :param segments.
217
+ * @param {Function} handler - (req, res) => void. req.params populated from path pattern.
218
+ * @param {Object} [opts]
219
+ * @param {number} [opts.priority=0] - Higher = matched first (before S3/static routes)
220
+ */
221
+ addCustomRoute(method, urlPath, handler, opts = {}) {
222
+ if (typeof method !== 'string' || !method) {
223
+ throw new Error('Route method must be a non-empty string');
224
+ }
225
+ if (typeof urlPath !== 'string') {
226
+ throw new Error('Route path must be a string');
227
+ }
228
+ if (typeof handler !== 'function') {
229
+ throw new Error('Route handler must be a function');
230
+ }
231
+
232
+ const normalizedMethod = method.toUpperCase();
233
+ const priority = opts.priority ?? 0;
234
+ const compiled = this._compilePath(urlPath);
235
+
236
+ // Remove existing route with same method + path (upsert)
237
+ this._customRoutes = this._customRoutes.filter(
238
+ r => !(r.method === normalizedMethod && r.path === urlPath)
239
+ );
240
+
241
+ this._customRoutes.push({
242
+ method: normalizedMethod,
243
+ path: urlPath,
244
+ handler,
245
+ priority,
246
+ ...compiled
247
+ });
248
+
249
+ // Sort descending by priority
250
+ this._customRoutes.sort((a, b) => b.priority - a.priority);
251
+
252
+ this.emit('route:added', { method: normalizedMethod, path: urlPath, priority });
253
+ }
254
+
255
+ /**
256
+ * Remove a custom route.
257
+ *
258
+ * @param {string} method - HTTP method
259
+ * @param {string} urlPath - URL path pattern
260
+ * @returns {boolean} true if route was found and removed
261
+ */
262
+ removeCustomRoute(method, urlPath) {
263
+ const normalizedMethod = method.toUpperCase();
264
+ const before = this._customRoutes.length;
265
+ this._customRoutes = this._customRoutes.filter(
266
+ r => !(r.method === normalizedMethod && r.path === urlPath)
267
+ );
268
+ const removed = this._customRoutes.length < before;
269
+ if (removed) {
270
+ this.emit('route:removed', { method: normalizedMethod, path: urlPath });
271
+ }
272
+ return removed;
273
+ }
274
+
275
+ /**
276
+ * Get the list of registered custom routes.
277
+ * @returns {Array<{ method: string, path: string, priority: number }>}
278
+ */
279
+ getCustomRoutes() {
280
+ return this._customRoutes.map(r => ({ method: r.method, path: r.path, priority: r.priority }));
281
+ }
282
+
283
+ /**
284
+ * Compile an Express-style path pattern into a regex and param name list.
285
+ *
286
+ * Supports:
287
+ * - Literal paths: `/v2/`
288
+ * - Named params: `/v2/:name/manifests/:reference`
289
+ *
290
+ * @private
291
+ * @param {string} pattern - URL path pattern
292
+ * @returns {{ regex: RegExp, paramNames: string[] }}
293
+ */
294
+ _compilePath(pattern) {
295
+ const paramNames = [];
296
+ // Escape special regex chars except : (used for params) and /
297
+ const regexStr = pattern
298
+ .replace(/([.+?^${}()|[\]\\])/g, '\\$1')
299
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
300
+ paramNames.push(paramName);
301
+ return '([^/]+)';
302
+ });
303
+
304
+ // Exact match — anchor both ends
305
+ const regex = new RegExp(`^${regexStr}$`);
306
+ return { regex, paramNames };
307
+ }
308
+
309
+ /**
310
+ * Run the middleware chain. Returns true if a middleware ended the response.
311
+ * @private
312
+ * @param {import('http').IncomingMessage} req
313
+ * @param {import('http').ServerResponse} res
314
+ * @returns {Promise<boolean>} true if response was ended by middleware
315
+ */
316
+ async _runMiddlewareChain(req, res) {
317
+ const middlewares = this._middlewares;
318
+ if (middlewares.length === 0) return false;
319
+
320
+ let index = 0;
321
+ let responseEnded = false;
322
+
323
+ const runNext = () => {
324
+ return new Promise((resolve, reject) => {
325
+ if (responseEnded || res.writableEnded) {
326
+ responseEnded = true;
327
+ return resolve();
328
+ }
329
+
330
+ if (index >= middlewares.length) {
331
+ return resolve(); // All middleware passed through
332
+ }
333
+
334
+ const mw = middlewares[index++];
335
+ try {
336
+ const result = mw.handler(req, res, (err) => {
337
+ if (err) {
338
+ return reject(err);
339
+ }
340
+ if (res.writableEnded) {
341
+ responseEnded = true;
342
+ return resolve();
343
+ }
344
+ runNext().then(resolve).catch(reject);
345
+ });
346
+
347
+ // Support async middleware that returns a promise
348
+ if (result && typeof result.then === 'function') {
349
+ result.catch(reject);
350
+ }
351
+ } catch (err) {
352
+ reject(err);
353
+ }
354
+ });
355
+ };
356
+
357
+ await runNext();
358
+ return responseEnded || res.writableEnded;
359
+ }
360
+
361
+ /**
362
+ * Try to match an incoming request against registered custom routes.
363
+ * @private
364
+ * @param {string} method - HTTP method
365
+ * @param {string} pathname - Decoded URL pathname
366
+ * @returns {{ route: object, params: object } | null}
367
+ */
368
+ _matchCustomRoute(method, pathname) {
369
+ const upperMethod = method.toUpperCase();
370
+
371
+ for (const route of this._customRoutes) {
372
+ if (route.method !== upperMethod) continue;
373
+
374
+ const match = route.regex.exec(pathname);
375
+ if (match) {
376
+ const params = {};
377
+ route.paramNames.forEach((name, i) => {
378
+ params[name] = decodeURIComponent(match[i + 1]);
379
+ });
380
+ return { route, params };
381
+ }
382
+ }
383
+
384
+ return null;
385
+ }
386
+
387
+ // ============================================================================
388
+ // REQUEST HANDLING
389
+ // ============================================================================
390
+
146
391
  /**
147
392
  * Create the main request handler
148
393
  * @private
@@ -163,7 +408,29 @@ export class HttpServerProvider extends EventEmitter {
163
408
  }
164
409
  }
165
410
 
166
- // Authentication
411
+ // ── Middleware chain (Gap 1) ──────────────────────────────────
412
+ // Run registered middleware before auth and routing.
413
+ // If any middleware ends the response, stop processing.
414
+ if (this._middlewares.length > 0) {
415
+ const handled = await this._runMiddlewareChain(req, res);
416
+ if (handled) return;
417
+ }
418
+
419
+ // ── Custom routes (Gap 2) ────────────────────────────────────
420
+ // Check registered custom routes before built-in handling.
421
+ const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
422
+ const urlPath = decodeURIComponent(urlObj.pathname);
423
+
424
+ if (this._customRoutes.length > 0) {
425
+ const routeMatch = this._matchCustomRoute(req.method, urlPath);
426
+ if (routeMatch) {
427
+ req.params = routeMatch.params;
428
+ await routeMatch.route.handler(req, res);
429
+ return;
430
+ }
431
+ }
432
+
433
+ // ── Built-in authentication ──────────────────────────────────
167
434
  if (this.config.auth) {
168
435
  if (!this._checkAuth(req)) {
169
436
  // Set appropriate WWW-Authenticate based on configured scheme
@@ -179,9 +446,7 @@ export class HttpServerProvider extends EventEmitter {
179
446
  }
180
447
  }
181
448
 
182
- // Route request
183
- const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
184
- const urlPath = decodeURIComponent(urlObj.pathname);
449
+ // ── Static file handling ─────────────────────────────────────
185
450
  const filePath = path.join(this.config.staticDir, urlPath);
186
451
 
187
452
  // 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
  /**
@@ -56,9 +70,12 @@ export class S3Server extends EventEmitter {
56
70
  */
57
71
  async start() {
58
72
  if (this.isRunning) {
73
+ const clientHost = (this.config.host === '0.0.0.0' || this.config.host === '::' || this.config.host === '[::]')
74
+ ? '127.0.0.1'
75
+ : this.config.host;
59
76
  return {
60
77
  port: this.config.port,
61
- endpoint: `http://${this.config.host}:${this.config.port}`
78
+ endpoint: `http://${clientHost}:${this.config.port}`
62
79
  };
63
80
  }
64
81
 
@@ -102,10 +119,17 @@ export class S3Server extends EventEmitter {
102
119
  const addr = this.server.address();
103
120
  this.config.port = addr.port;
104
121
  this.isRunning = true;
122
+ this._serverId = `kadi-s3-${addr.port}`;
105
123
 
124
+ // Use a client-connectable host in the endpoint (0.0.0.0 is a bind address, not routable)
125
+ const clientHost = (this.config.host === '0.0.0.0' || this.config.host === '::' || this.config.host === '[::]')
126
+ ? '127.0.0.1'
127
+ : this.config.host;
106
128
  const info = {
107
129
  port: addr.port,
108
- endpoint: `http://${this.config.host}:${addr.port}`
130
+ host: this.config.host,
131
+ endpoint: `http://${clientHost}:${addr.port}`,
132
+ serverId: this._serverId
109
133
  };
110
134
 
111
135
  this.emit('started', info);
@@ -893,10 +917,170 @@ export class S3Server extends EventEmitter {
893
917
  res.end(xml);
894
918
  }
895
919
 
920
+ // ============================================================================
921
+ // PUBLIC EXTENSIBILITY APIs
922
+ // ============================================================================
923
+
924
+ /**
925
+ * Validate an incoming request against the server's configured credentials.
926
+ * Returns a structured result object (unlike the private _checkS3Auth which
927
+ * returns a plain boolean).
928
+ *
929
+ * This is the public API that consumers like container-registry-ability use
930
+ * to validate Docker Registry API requests carry valid S3 credentials.
931
+ *
932
+ * @param {import('http').IncomingMessage} req
933
+ * @returns {{ success: boolean, user?: { accessKey: string }, error?: string }}
934
+ */
935
+ validateAuthentication(req) {
936
+ // Attempt to extract the access key from the request for the user object
937
+ const accessKey = this._extractAccessKey(req);
938
+ const isValid = this._checkS3Auth(req);
939
+
940
+ if (isValid) {
941
+ return {
942
+ success: true,
943
+ user: { accessKey: accessKey || this.config.accessKeyId }
944
+ };
945
+ }
946
+
947
+ return {
948
+ success: false,
949
+ error: 'Invalid credentials'
950
+ };
951
+ }
952
+
953
+ /**
954
+ * Programmatically remove a bucket (delete its directory from the S3 root).
955
+ * Unlike the S3 API DeleteBucket handler, this force-deletes even non-empty
956
+ * directories — intended for cleanup when containers are removed.
957
+ *
958
+ * @param {string} bucketName - The bucket/directory name to remove
959
+ * @returns {Promise<void>}
960
+ */
961
+ async removeBucket(bucketName) {
962
+ if (!bucketName || typeof bucketName !== 'string') {
963
+ throw new Error('bucketName must be a non-empty string');
964
+ }
965
+
966
+ // Security: prevent directory traversal
967
+ if (bucketName.includes('..') || bucketName.includes('/') || bucketName.includes('\\')) {
968
+ throw new Error('Invalid bucket name — must not contain path separators or ".."');
969
+ }
970
+
971
+ const bucketDir = path.join(this.config.rootDir, bucketName);
972
+
973
+ // Ensure the resolved path is within rootDir
974
+ const resolvedRoot = path.resolve(this.config.rootDir);
975
+ const resolvedBucket = path.resolve(bucketDir);
976
+ if (!resolvedBucket.startsWith(resolvedRoot)) {
977
+ throw new Error('Invalid bucket name — path traversal detected');
978
+ }
979
+
980
+ try {
981
+ await fs.rm(bucketDir, { recursive: true, force: true });
982
+ this.emit('bucket:removed', { bucket: bucketName });
983
+ } catch (error) {
984
+ if (error.code !== 'ENOENT') {
985
+ throw error;
986
+ }
987
+ // Bucket doesn't exist — treat as success (idempotent)
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Generate temporary S3 credentials with an expiry.
993
+ *
994
+ * Returns both `expiresAt` (ISO string) and `expiry` (Date object) for
995
+ * backward compatibility with consumers expecting the old property name.
996
+ *
997
+ * @param {Object} [opts]
998
+ * @param {number} [opts.ttl=3600] - Time-to-live in seconds (default: 1 hour)
999
+ * @returns {{ accessKey: string, secretKey: string, expiresAt: string, expiry: Date }}
1000
+ */
1001
+ generateTemporaryCredentials(opts = {}) {
1002
+ const ttl = opts.ttl ?? 3600;
1003
+ const accessKey = `AKIA${crypto.randomBytes(8).toString('hex').toUpperCase()}`;
1004
+ const secretKey = crypto.randomBytes(20).toString('hex');
1005
+ const expiryDate = new Date(Date.now() + ttl * 1000);
1006
+
1007
+ // Store for validation
1008
+ this._temporaryCredentials.set(accessKey, {
1009
+ secretKey,
1010
+ expiresAt: expiryDate.getTime()
1011
+ });
1012
+
1013
+ // Schedule cleanup
1014
+ setTimeout(() => {
1015
+ this._temporaryCredentials.delete(accessKey);
1016
+ }, ttl * 1000).unref();
1017
+
1018
+ this.emit('credentials:generated', { accessKey, expiresAt: expiryDate.toISOString() });
1019
+
1020
+ return {
1021
+ accessKey,
1022
+ secretKey,
1023
+ expiresAt: expiryDate.toISOString(),
1024
+ expiry: expiryDate // backward compat alias
1025
+ };
1026
+ }
1027
+
896
1028
  // ============================================================================
897
1029
  // AUTHENTICATION
898
1030
  // ============================================================================
899
1031
 
1032
+ /**
1033
+ * Extract the access key from an incoming request, if present.
1034
+ * Used by validateAuthentication() to populate the user object.
1035
+ *
1036
+ * @private
1037
+ * @param {import('http').IncomingMessage} req
1038
+ * @returns {string|null}
1039
+ */
1040
+ _extractAccessKey(req) {
1041
+ const authHeader = req.headers.authorization || '';
1042
+
1043
+ // AWS Signature V4
1044
+ if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
1045
+ const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
1046
+ if (credMatch) return credMatch[1];
1047
+ }
1048
+
1049
+ // AWS Signature V2
1050
+ if (authHeader.startsWith('AWS ')) {
1051
+ return authHeader.slice(4).split(':')[0] || null;
1052
+ }
1053
+
1054
+ // Bearer
1055
+ if (authHeader.startsWith('Bearer ')) {
1056
+ return authHeader.slice(7) || null;
1057
+ }
1058
+
1059
+ // Basic auth (Docker clients)
1060
+ if (authHeader.startsWith('Basic ')) {
1061
+ try {
1062
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
1063
+ const [user] = decoded.split(':');
1064
+ return user || null;
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ }
1069
+
1070
+ // Query params
1071
+ try {
1072
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1073
+ const amzCred = url.searchParams.get('X-Amz-Credential');
1074
+ if (amzCred) return amzCred.split('/')[0];
1075
+ const awsKey = url.searchParams.get('AWSAccessKeyId');
1076
+ if (awsKey) return awsKey;
1077
+ } catch {
1078
+ // ignore
1079
+ }
1080
+
1081
+ return null;
1082
+ }
1083
+
900
1084
  /**
901
1085
  * Validate S3 request authentication.
902
1086
  *
@@ -907,17 +1091,13 @@ export class S3Server extends EventEmitter {
907
1091
  * - AWS Signature V4: `AWS4-HMAC-SHA256 Credential=<accessKeyId>/…`
908
1092
  * - AWS Signature V2: `AWS <accessKeyId>:…`
909
1093
  * - Simple Bearer: `Bearer <accessKeyId>` (non-standard convenience)
1094
+ * - Basic auth: `Basic base64(accessKey:secretKey)` (Docker clients)
910
1095
  *
911
1096
  * 2. Query-string pre-signed URL parameters
912
1097
  * - V4: `?X-Amz-Credential=<accessKeyId>/…`
913
1098
  * - V2: `?AWSAccessKeyId=<accessKeyId>`
914
1099
  *
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.
1100
+ * Also checks temporary credentials generated via generateTemporaryCredentials().
921
1101
  *
922
1102
  * @private
923
1103
  * @param {import('http').IncomingMessage} req
@@ -939,23 +1119,41 @@ export class S3Server extends EventEmitter {
939
1119
  // AWS Signature V4: "AWS4-HMAC-SHA256 Credential=<accessKeyId>/date/region/s3/aws4_request, …"
940
1120
  if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
941
1121
  const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
942
- if (credMatch && credMatch[1] === accessKeyId) {
943
- return true;
1122
+ if (credMatch) {
1123
+ if (credMatch[1] === accessKeyId) return true;
1124
+ // Check temporary credentials
1125
+ if (this._checkTempCredentials(credMatch[1])) return true;
944
1126
  }
945
1127
  }
946
1128
 
947
1129
  // AWS Signature V2: "AWS <accessKeyId>:<signature>"
948
1130
  if (authHeader.startsWith('AWS ')) {
949
1131
  const parts = authHeader.slice(4).split(':');
950
- if (parts[0] === accessKeyId) {
951
- return true;
952
- }
1132
+ if (parts[0] === accessKeyId) return true;
1133
+ if (this._checkTempCredentials(parts[0])) return true;
953
1134
  }
954
1135
 
955
1136
  // Simple Bearer (non-standard convenience for agents/scripts)
956
1137
  if (authHeader.startsWith('Bearer ')) {
957
- if (authHeader.slice(7) === accessKeyId) {
958
- return true;
1138
+ const token = authHeader.slice(7);
1139
+ if (token === accessKeyId) return true;
1140
+ if (this._checkTempCredentials(token)) return true;
1141
+ }
1142
+
1143
+ // Basic auth (Docker clients send Basic base64(accessKey:secretKey))
1144
+ if (authHeader.startsWith('Basic ')) {
1145
+ try {
1146
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
1147
+ const [user, pass] = decoded.split(':');
1148
+ // Check against primary credentials
1149
+ if (user === accessKeyId && pass === secretAccessKey) return true;
1150
+ // Check against temporary credentials
1151
+ const tempCred = this._temporaryCredentials.get(user);
1152
+ if (tempCred && tempCred.expiresAt > Date.now() && pass === tempCred.secretKey) {
1153
+ return true;
1154
+ }
1155
+ } catch {
1156
+ // Malformed Basic auth — fall through
959
1157
  }
960
1158
  }
961
1159
 
@@ -965,13 +1163,17 @@ export class S3Server extends EventEmitter {
965
1163
 
966
1164
  // V4 pre-signed: ?X-Amz-Credential=<accessKeyId>/…
967
1165
  const amzCred = url.searchParams.get('X-Amz-Credential');
968
- if (amzCred && amzCred.startsWith(accessKeyId + '/')) {
969
- return true;
1166
+ if (amzCred) {
1167
+ const credKey = amzCred.split('/')[0];
1168
+ if (credKey === accessKeyId) return true;
1169
+ if (this._checkTempCredentials(credKey)) return true;
970
1170
  }
971
1171
 
972
1172
  // V2 pre-signed: ?AWSAccessKeyId=<accessKeyId>
973
- if (url.searchParams.get('AWSAccessKeyId') === accessKeyId) {
974
- return true;
1173
+ const awsKey = url.searchParams.get('AWSAccessKeyId');
1174
+ if (awsKey) {
1175
+ if (awsKey === accessKeyId) return true;
1176
+ if (this._checkTempCredentials(awsKey)) return true;
975
1177
  }
976
1178
  } catch {
977
1179
  // Malformed URL — deny
@@ -979,6 +1181,26 @@ export class S3Server extends EventEmitter {
979
1181
 
980
1182
  return false;
981
1183
  }
1184
+
1185
+ /**
1186
+ * Check if an access key matches a valid, non-expired temporary credential.
1187
+ *
1188
+ * @private
1189
+ * @param {string} accessKey
1190
+ * @returns {boolean}
1191
+ */
1192
+ _checkTempCredentials(accessKey) {
1193
+ const cred = this._temporaryCredentials.get(accessKey);
1194
+ if (!cred) return false;
1195
+
1196
+ // Expired?
1197
+ if (cred.expiresAt <= Date.now()) {
1198
+ this._temporaryCredentials.delete(accessKey);
1199
+ return false;
1200
+ }
1201
+
1202
+ return true;
1203
+ }
982
1204
  }
983
1205
 
984
1206
  export default S3Server;