@push.rocks/smartregistry 2.6.0 → 2.8.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.
Files changed (52) hide show
  1. package/.smartconfig.json +24 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/cargo/classes.cargoregistry.d.ts +8 -3
  4. package/dist_ts/cargo/classes.cargoregistry.js +71 -33
  5. package/dist_ts/classes.smartregistry.js +48 -36
  6. package/dist_ts/composer/classes.composerregistry.d.ts +14 -3
  7. package/dist_ts/composer/classes.composerregistry.js +64 -28
  8. package/dist_ts/core/classes.registrystorage.d.ts +45 -0
  9. package/dist_ts/core/classes.registrystorage.js +116 -1
  10. package/dist_ts/core/helpers.stream.d.ts +20 -0
  11. package/dist_ts/core/helpers.stream.js +59 -0
  12. package/dist_ts/core/index.d.ts +1 -0
  13. package/dist_ts/core/index.js +3 -1
  14. package/dist_ts/core/interfaces.core.d.ts +28 -5
  15. package/dist_ts/maven/classes.mavenregistry.d.ts +14 -3
  16. package/dist_ts/maven/classes.mavenregistry.js +78 -27
  17. package/dist_ts/npm/classes.npmregistry.d.ts +14 -3
  18. package/dist_ts/npm/classes.npmregistry.js +121 -48
  19. package/dist_ts/oci/classes.ociregistry.d.ts +19 -3
  20. package/dist_ts/oci/classes.ociregistry.js +187 -73
  21. package/dist_ts/oci/classes.ociupstream.d.ts +5 -2
  22. package/dist_ts/oci/classes.ociupstream.js +17 -10
  23. package/dist_ts/oci/interfaces.oci.d.ts +4 -0
  24. package/dist_ts/pypi/classes.pypiregistry.d.ts +8 -3
  25. package/dist_ts/pypi/classes.pypiregistry.js +88 -50
  26. package/dist_ts/rubygems/classes.rubygemsregistry.d.ts +8 -3
  27. package/dist_ts/rubygems/classes.rubygemsregistry.js +61 -23
  28. package/dist_ts/rubygems/helpers.rubygems.js +9 -11
  29. package/dist_ts/upstream/classes.upstreamcache.js +2 -2
  30. package/dist_ts/upstream/interfaces.upstream.d.ts +72 -1
  31. package/dist_ts/upstream/interfaces.upstream.js +24 -1
  32. package/package.json +24 -20
  33. package/readme.md +354 -812
  34. package/ts/00_commitinfo_data.ts +1 -1
  35. package/ts/cargo/classes.cargoregistry.ts +84 -37
  36. package/ts/classes.smartregistry.ts +49 -35
  37. package/ts/composer/classes.composerregistry.ts +74 -30
  38. package/ts/core/classes.registrystorage.ts +133 -2
  39. package/ts/core/helpers.stream.ts +63 -0
  40. package/ts/core/index.ts +3 -0
  41. package/ts/core/interfaces.core.ts +29 -5
  42. package/ts/maven/classes.mavenregistry.ts +89 -28
  43. package/ts/npm/classes.npmregistry.ts +134 -49
  44. package/ts/oci/classes.ociregistry.ts +206 -77
  45. package/ts/oci/classes.ociupstream.ts +18 -8
  46. package/ts/oci/interfaces.oci.ts +4 -0
  47. package/ts/pypi/classes.pypiregistry.ts +100 -54
  48. package/ts/rubygems/classes.rubygemsregistry.ts +69 -24
  49. package/ts/rubygems/helpers.rubygems.ts +8 -10
  50. package/ts/upstream/classes.upstreamcache.ts +1 -1
  51. package/ts/upstream/interfaces.upstream.ts +82 -1
  52. package/npmextra.json +0 -18
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartregistry',
6
- version: '2.6.0',
6
+ version: '2.8.1',
7
7
  description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
8
8
  }
@@ -2,8 +2,8 @@ import { Smartlog } from '@push.rocks/smartlog';
2
2
  import { BaseRegistry } from '../core/classes.baseregistry.js';
3
3
  import { RegistryStorage } from '../core/classes.registrystorage.js';
4
4
  import { AuthManager } from '../core/classes.authmanager.js';
5
- import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
6
- import type { IProtocolUpstreamConfig } from '../upstream/interfaces.upstream.js';
5
+ import type { IRequestContext, IResponse, IAuthToken, IRequestActor } from '../core/interfaces.core.js';
6
+ import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
7
7
  import type {
8
8
  ICargoIndexEntry,
9
9
  ICargoPublishMetadata,
@@ -27,20 +27,21 @@ export class CargoRegistry extends BaseRegistry {
27
27
  private basePath: string = '/cargo';
28
28
  private registryUrl: string;
29
29
  private logger: Smartlog;
30
- private upstream: CargoUpstream | null = null;
30
+ private upstreamProvider: IUpstreamProvider | null = null;
31
31
 
32
32
  constructor(
33
33
  storage: RegistryStorage,
34
34
  authManager: AuthManager,
35
35
  basePath: string = '/cargo',
36
36
  registryUrl: string = 'http://localhost:5000/cargo',
37
- upstreamConfig?: IProtocolUpstreamConfig
37
+ upstreamProvider?: IUpstreamProvider
38
38
  ) {
39
39
  super();
40
40
  this.storage = storage;
41
41
  this.authManager = authManager;
42
42
  this.basePath = basePath;
43
43
  this.registryUrl = registryUrl;
44
+ this.upstreamProvider = upstreamProvider || null;
44
45
 
45
46
  // Initialize logger
46
47
  this.logger = new Smartlog({
@@ -54,20 +55,38 @@ export class CargoRegistry extends BaseRegistry {
54
55
  }
55
56
  });
56
57
  this.logger.enableConsole();
58
+ }
57
59
 
58
- // Initialize upstream if configured
59
- if (upstreamConfig?.enabled) {
60
- this.upstream = new CargoUpstream(upstreamConfig, undefined, this.logger);
61
- }
60
+ /**
61
+ * Get upstream for a specific request.
62
+ * Calls the provider to resolve upstream config dynamically.
63
+ */
64
+ private async getUpstreamForRequest(
65
+ resource: string,
66
+ resourceType: string,
67
+ method: string,
68
+ actor?: IRequestActor
69
+ ): Promise<CargoUpstream | null> {
70
+ if (!this.upstreamProvider) return null;
71
+
72
+ const config = await this.upstreamProvider.resolveUpstreamConfig({
73
+ protocol: 'cargo',
74
+ resource,
75
+ scope: resource, // For Cargo, crate name is the scope
76
+ actor,
77
+ method,
78
+ resourceType,
79
+ });
80
+
81
+ if (!config?.enabled) return null;
82
+ return new CargoUpstream(config, undefined, this.logger);
62
83
  }
63
84
 
64
85
  /**
65
86
  * Clean up resources (timers, connections, etc.)
66
87
  */
67
88
  public destroy(): void {
68
- if (this.upstream) {
69
- this.upstream.stop();
70
- }
89
+ // No persistent upstream to clean up with dynamic provider
71
90
  }
72
91
 
73
92
  public async init(): Promise<void> {
@@ -94,6 +113,14 @@ export class CargoRegistry extends BaseRegistry {
94
113
  const authHeader = context.headers['authorization'] || context.headers['Authorization'];
95
114
  const token = authHeader ? await this.authManager.validateToken(authHeader, 'cargo') : null;
96
115
 
116
+ // Build actor from context and validated token
117
+ const actor: IRequestActor = {
118
+ ...context.actor,
119
+ userId: token?.userId,
120
+ ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
121
+ userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
122
+ };
123
+
97
124
  this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
98
125
  method: context.method,
99
126
  path,
@@ -107,11 +134,11 @@ export class CargoRegistry extends BaseRegistry {
107
134
 
108
135
  // API endpoints
109
136
  if (path.startsWith('/api/v1/')) {
110
- return this.handleApiRequest(path, context, token);
137
+ return this.handleApiRequest(path, context, token, actor);
111
138
  }
112
139
 
113
140
  // Index files (sparse protocol)
114
- return this.handleIndexRequest(path);
141
+ return this.handleIndexRequest(path, actor);
115
142
  }
116
143
 
117
144
  /**
@@ -132,7 +159,8 @@ export class CargoRegistry extends BaseRegistry {
132
159
  private async handleApiRequest(
133
160
  path: string,
134
161
  context: IRequestContext,
135
- token: IAuthToken | null
162
+ token: IAuthToken | null,
163
+ actor?: IRequestActor
136
164
  ): Promise<IResponse> {
137
165
  // Publish: PUT /api/v1/crates/new
138
166
  if (path === '/api/v1/crates/new' && context.method === 'PUT') {
@@ -142,7 +170,7 @@ export class CargoRegistry extends BaseRegistry {
142
170
  // Download: GET /api/v1/crates/{crate}/{version}/download
143
171
  const downloadMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/download$/);
144
172
  if (downloadMatch && context.method === 'GET') {
145
- return this.handleDownload(downloadMatch[1], downloadMatch[2]);
173
+ return this.handleDownload(downloadMatch[1], downloadMatch[2], actor);
146
174
  }
147
175
 
148
176
  // Yank: DELETE /api/v1/crates/{crate}/{version}/yank
@@ -175,7 +203,7 @@ export class CargoRegistry extends BaseRegistry {
175
203
  * Handle index file requests
176
204
  * Paths: /1/{name}, /2/{name}, /3/{c}/{name}, /{p1}/{p2}/{name}
177
205
  */
178
- private async handleIndexRequest(path: string): Promise<IResponse> {
206
+ private async handleIndexRequest(path: string, actor?: IRequestActor): Promise<IResponse> {
179
207
  // Parse index paths to extract crate name
180
208
  const pathParts = path.split('/').filter(p => p);
181
209
  let crateName: string | null = null;
@@ -202,7 +230,7 @@ export class CargoRegistry extends BaseRegistry {
202
230
  };
203
231
  }
204
232
 
205
- return this.handleIndexFile(crateName);
233
+ return this.handleIndexFile(crateName, actor);
206
234
  }
207
235
 
208
236
  /**
@@ -224,23 +252,26 @@ export class CargoRegistry extends BaseRegistry {
224
252
  /**
225
253
  * Serve index file for a crate
226
254
  */
227
- private async handleIndexFile(crateName: string): Promise<IResponse> {
255
+ private async handleIndexFile(crateName: string, actor?: IRequestActor): Promise<IResponse> {
228
256
  let index = await this.storage.getCargoIndex(crateName);
229
257
 
230
258
  // Try upstream if not found locally
231
- if ((!index || index.length === 0) && this.upstream) {
232
- const upstreamIndex = await this.upstream.fetchCrateIndex(crateName);
233
- if (upstreamIndex) {
234
- // Parse the newline-delimited JSON
235
- const parsedIndex: ICargoIndexEntry[] = upstreamIndex
236
- .split('\n')
237
- .filter(line => line.trim())
238
- .map(line => JSON.parse(line));
239
-
240
- if (parsedIndex.length > 0) {
241
- // Cache locally
242
- await this.storage.putCargoIndex(crateName, parsedIndex);
243
- index = parsedIndex;
259
+ if (!index || index.length === 0) {
260
+ const upstream = await this.getUpstreamForRequest(crateName, 'index', 'GET', actor);
261
+ if (upstream) {
262
+ const upstreamIndex = await upstream.fetchCrateIndex(crateName);
263
+ if (upstreamIndex) {
264
+ // Parse the newline-delimited JSON
265
+ const parsedIndex: ICargoIndexEntry[] = upstreamIndex
266
+ .split('\n')
267
+ .filter(line => line.trim())
268
+ .map(line => JSON.parse(line));
269
+
270
+ if (parsedIndex.length > 0) {
271
+ // Cache locally
272
+ await this.storage.putCargoIndex(crateName, parsedIndex);
273
+ index = parsedIndex;
274
+ }
244
275
  }
245
276
  }
246
277
  }
@@ -339,7 +370,7 @@ export class CargoRegistry extends BaseRegistry {
339
370
  const parsed = this.parsePublishRequest(body);
340
371
  metadata = parsed.metadata;
341
372
  crateFile = parsed.crateFile;
342
- } catch (error) {
373
+ } catch (error: any) {
343
374
  this.logger.log('error', 'handlePublish: parse error', { error: error.message });
344
375
  return {
345
376
  status: 400,
@@ -431,15 +462,31 @@ export class CargoRegistry extends BaseRegistry {
431
462
  */
432
463
  private async handleDownload(
433
464
  crateName: string,
434
- version: string
465
+ version: string,
466
+ actor?: IRequestActor
435
467
  ): Promise<IResponse> {
436
468
  this.logger.log('debug', 'handleDownload', { crate: crateName, version });
437
469
 
438
- let crateFile = await this.storage.getCargoCrate(crateName, version);
470
+ // Try streaming from local storage first
471
+ const streamResult = await this.storage.getCargoCrateStream(crateName, version);
472
+
473
+ if (streamResult) {
474
+ return {
475
+ status: 200,
476
+ headers: {
477
+ 'Content-Type': 'application/gzip',
478
+ 'Content-Length': streamResult.size.toString(),
479
+ 'Content-Disposition': `attachment; filename="${crateName}-${version}.crate"`,
480
+ },
481
+ body: streamResult.stream,
482
+ };
483
+ }
439
484
 
440
485
  // Try upstream if not found locally
441
- if (!crateFile && this.upstream) {
442
- crateFile = await this.upstream.fetchCrate(crateName, version);
486
+ let crateFile: Buffer | null = null;
487
+ const upstream = await this.getUpstreamForRequest(crateName, 'crate', 'GET', actor);
488
+ if (upstream) {
489
+ crateFile = await upstream.fetchCrate(crateName, version);
443
490
  if (crateFile) {
444
491
  // Cache locally
445
492
  await this.storage.putCargoCrate(crateName, version, crateFile);
@@ -612,7 +659,7 @@ export class CargoRegistry extends BaseRegistry {
612
659
  }
613
660
  }
614
661
  }
615
- } catch (error) {
662
+ } catch (error: any) {
616
663
  this.logger.log('error', 'handleSearch: error', { error: error.message });
617
664
  }
618
665
 
@@ -2,6 +2,7 @@ import { RegistryStorage } from './core/classes.registrystorage.js';
2
2
  import { AuthManager } from './core/classes.authmanager.js';
3
3
  import { BaseRegistry } from './core/classes.baseregistry.js';
4
4
  import type { IRegistryConfig, IRequestContext, IResponse } from './core/interfaces.core.js';
5
+ import { toReadableStream } from './core/helpers.stream.js';
5
6
  import { OciRegistry } from './oci/classes.ociregistry.js';
6
7
  import { NpmRegistry } from './npm/classes.npmregistry.js';
7
8
  import { MavenRegistry } from './maven/classes.mavenregistry.js';
@@ -86,7 +87,7 @@ export class SmartRegistry {
86
87
  this.authManager,
87
88
  ociBasePath,
88
89
  ociTokens,
89
- this.config.oci.upstream
90
+ this.config.upstreamProvider
90
91
  );
91
92
  await ociRegistry.init();
92
93
  this.registries.set('oci', ociRegistry);
@@ -95,13 +96,13 @@ export class SmartRegistry {
95
96
  // Initialize NPM registry if enabled
96
97
  if (this.config.npm?.enabled) {
97
98
  const npmBasePath = this.config.npm.basePath ?? '/npm';
98
- const registryUrl = `http://localhost:5000${npmBasePath}`; // TODO: Make configurable
99
+ const registryUrl = this.config.npm.registryUrl ?? `http://localhost:5000${npmBasePath}`;
99
100
  const npmRegistry = new NpmRegistry(
100
101
  this.storage,
101
102
  this.authManager,
102
103
  npmBasePath,
103
104
  registryUrl,
104
- this.config.npm.upstream
105
+ this.config.upstreamProvider
105
106
  );
106
107
  await npmRegistry.init();
107
108
  this.registries.set('npm', npmRegistry);
@@ -110,13 +111,13 @@ export class SmartRegistry {
110
111
  // Initialize Maven registry if enabled
111
112
  if (this.config.maven?.enabled) {
112
113
  const mavenBasePath = this.config.maven.basePath ?? '/maven';
113
- const registryUrl = `http://localhost:5000${mavenBasePath}`; // TODO: Make configurable
114
+ const registryUrl = this.config.maven.registryUrl ?? `http://localhost:5000${mavenBasePath}`;
114
115
  const mavenRegistry = new MavenRegistry(
115
116
  this.storage,
116
117
  this.authManager,
117
118
  mavenBasePath,
118
119
  registryUrl,
119
- this.config.maven.upstream
120
+ this.config.upstreamProvider
120
121
  );
121
122
  await mavenRegistry.init();
122
123
  this.registries.set('maven', mavenRegistry);
@@ -125,13 +126,13 @@ export class SmartRegistry {
125
126
  // Initialize Cargo registry if enabled
126
127
  if (this.config.cargo?.enabled) {
127
128
  const cargoBasePath = this.config.cargo.basePath ?? '/cargo';
128
- const registryUrl = `http://localhost:5000${cargoBasePath}`; // TODO: Make configurable
129
+ const registryUrl = this.config.cargo.registryUrl ?? `http://localhost:5000${cargoBasePath}`;
129
130
  const cargoRegistry = new CargoRegistry(
130
131
  this.storage,
131
132
  this.authManager,
132
133
  cargoBasePath,
133
134
  registryUrl,
134
- this.config.cargo.upstream
135
+ this.config.upstreamProvider
135
136
  );
136
137
  await cargoRegistry.init();
137
138
  this.registries.set('cargo', cargoRegistry);
@@ -140,13 +141,13 @@ export class SmartRegistry {
140
141
  // Initialize Composer registry if enabled
141
142
  if (this.config.composer?.enabled) {
142
143
  const composerBasePath = this.config.composer.basePath ?? '/composer';
143
- const registryUrl = `http://localhost:5000${composerBasePath}`; // TODO: Make configurable
144
+ const registryUrl = this.config.composer.registryUrl ?? `http://localhost:5000${composerBasePath}`;
144
145
  const composerRegistry = new ComposerRegistry(
145
146
  this.storage,
146
147
  this.authManager,
147
148
  composerBasePath,
148
149
  registryUrl,
149
- this.config.composer.upstream
150
+ this.config.upstreamProvider
150
151
  );
151
152
  await composerRegistry.init();
152
153
  this.registries.set('composer', composerRegistry);
@@ -155,13 +156,13 @@ export class SmartRegistry {
155
156
  // Initialize PyPI registry if enabled
156
157
  if (this.config.pypi?.enabled) {
157
158
  const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
158
- const registryUrl = `http://localhost:5000`; // TODO: Make configurable
159
+ const registryUrl = this.config.pypi.registryUrl ?? `http://localhost:5000`;
159
160
  const pypiRegistry = new PypiRegistry(
160
161
  this.storage,
161
162
  this.authManager,
162
163
  pypiBasePath,
163
164
  registryUrl,
164
- this.config.pypi.upstream
165
+ this.config.upstreamProvider
165
166
  );
166
167
  await pypiRegistry.init();
167
168
  this.registries.set('pypi', pypiRegistry);
@@ -170,13 +171,13 @@ export class SmartRegistry {
170
171
  // Initialize RubyGems registry if enabled
171
172
  if (this.config.rubygems?.enabled) {
172
173
  const rubygemsBasePath = this.config.rubygems.basePath ?? '/rubygems';
173
- const registryUrl = `http://localhost:5000${rubygemsBasePath}`; // TODO: Make configurable
174
+ const registryUrl = this.config.rubygems.registryUrl ?? `http://localhost:5000${rubygemsBasePath}`;
174
175
  const rubygemsRegistry = new RubyGemsRegistry(
175
176
  this.storage,
176
177
  this.authManager,
177
178
  rubygemsBasePath,
178
179
  registryUrl,
179
- this.config.rubygems.upstream
180
+ this.config.upstreamProvider
180
181
  );
181
182
  await rubygemsRegistry.init();
182
183
  this.registries.set('rubygems', rubygemsRegistry);
@@ -191,75 +192,88 @@ export class SmartRegistry {
191
192
  */
192
193
  public async handleRequest(context: IRequestContext): Promise<IResponse> {
193
194
  const path = context.path;
195
+ let response: IResponse | undefined;
194
196
 
195
197
  // Route to OCI registry
196
- if (this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) {
198
+ if (!response && this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) {
197
199
  const ociRegistry = this.registries.get('oci');
198
200
  if (ociRegistry) {
199
- return ociRegistry.handleRequest(context);
201
+ response = await ociRegistry.handleRequest(context);
200
202
  }
201
203
  }
202
204
 
203
205
  // Route to NPM registry
204
- if (this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) {
206
+ if (!response && this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) {
205
207
  const npmRegistry = this.registries.get('npm');
206
208
  if (npmRegistry) {
207
- return npmRegistry.handleRequest(context);
209
+ response = await npmRegistry.handleRequest(context);
208
210
  }
209
211
  }
210
212
 
211
213
  // Route to Maven registry
212
- if (this.config.maven?.enabled && path.startsWith(this.config.maven.basePath)) {
214
+ if (!response && this.config.maven?.enabled && path.startsWith(this.config.maven.basePath)) {
213
215
  const mavenRegistry = this.registries.get('maven');
214
216
  if (mavenRegistry) {
215
- return mavenRegistry.handleRequest(context);
217
+ response = await mavenRegistry.handleRequest(context);
216
218
  }
217
219
  }
218
220
 
219
221
  // Route to Cargo registry
220
- if (this.config.cargo?.enabled && path.startsWith(this.config.cargo.basePath)) {
222
+ if (!response && this.config.cargo?.enabled && path.startsWith(this.config.cargo.basePath)) {
221
223
  const cargoRegistry = this.registries.get('cargo');
222
224
  if (cargoRegistry) {
223
- return cargoRegistry.handleRequest(context);
225
+ response = await cargoRegistry.handleRequest(context);
224
226
  }
225
227
  }
226
228
 
227
229
  // Route to Composer registry
228
- if (this.config.composer?.enabled && path.startsWith(this.config.composer.basePath)) {
230
+ if (!response && this.config.composer?.enabled && path.startsWith(this.config.composer.basePath)) {
229
231
  const composerRegistry = this.registries.get('composer');
230
232
  if (composerRegistry) {
231
- return composerRegistry.handleRequest(context);
233
+ response = await composerRegistry.handleRequest(context);
232
234
  }
233
235
  }
234
236
 
235
237
  // Route to PyPI registry (also handles /simple prefix)
236
- if (this.config.pypi?.enabled) {
238
+ if (!response && this.config.pypi?.enabled) {
237
239
  const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
238
240
  if (path.startsWith(pypiBasePath) || path.startsWith('/simple')) {
239
241
  const pypiRegistry = this.registries.get('pypi');
240
242
  if (pypiRegistry) {
241
- return pypiRegistry.handleRequest(context);
243
+ response = await pypiRegistry.handleRequest(context);
242
244
  }
243
245
  }
244
246
  }
245
247
 
246
248
  // Route to RubyGems registry
247
- if (this.config.rubygems?.enabled && path.startsWith(this.config.rubygems.basePath)) {
249
+ if (!response && this.config.rubygems?.enabled && path.startsWith(this.config.rubygems.basePath)) {
248
250
  const rubygemsRegistry = this.registries.get('rubygems');
249
251
  if (rubygemsRegistry) {
250
- return rubygemsRegistry.handleRequest(context);
252
+ response = await rubygemsRegistry.handleRequest(context);
251
253
  }
252
254
  }
253
255
 
254
256
  // No matching registry
255
- return {
256
- status: 404,
257
- headers: { 'Content-Type': 'application/json' },
258
- body: {
259
- error: 'NOT_FOUND',
260
- message: 'No registry handler for this path',
261
- },
262
- };
257
+ if (!response) {
258
+ response = {
259
+ status: 404,
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: {
262
+ error: 'NOT_FOUND',
263
+ message: 'No registry handler for this path',
264
+ },
265
+ };
266
+ }
267
+
268
+ // Normalize body to ReadableStream<Uint8Array> at the API boundary
269
+ if (response.body != null && !(response.body instanceof ReadableStream)) {
270
+ if (!Buffer.isBuffer(response.body) && typeof response.body === 'object' && !(response.body instanceof Uint8Array)) {
271
+ response.headers['Content-Type'] ??= 'application/json';
272
+ }
273
+ response.body = toReadableStream(response.body);
274
+ }
275
+
276
+ return response;
263
277
  }
264
278
 
265
279
  /**
@@ -6,8 +6,8 @@
6
6
  import { BaseRegistry } from '../core/classes.baseregistry.js';
7
7
  import type { RegistryStorage } from '../core/classes.registrystorage.js';
8
8
  import type { AuthManager } from '../core/classes.authmanager.js';
9
- import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
10
- import type { IProtocolUpstreamConfig } from '../upstream/interfaces.upstream.js';
9
+ import type { IRequestContext, IResponse, IAuthToken, IRequestActor } from '../core/interfaces.core.js';
10
+ import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
11
11
  import { isBinaryData, toBuffer } from '../core/helpers.buffer.js';
12
12
  import type {
13
13
  IComposerPackage,
@@ -30,34 +30,66 @@ export class ComposerRegistry extends BaseRegistry {
30
30
  private authManager: AuthManager;
31
31
  private basePath: string = '/composer';
32
32
  private registryUrl: string;
33
- private upstream: ComposerUpstream | null = null;
33
+ private upstreamProvider: IUpstreamProvider | null = null;
34
34
 
35
35
  constructor(
36
36
  storage: RegistryStorage,
37
37
  authManager: AuthManager,
38
38
  basePath: string = '/composer',
39
39
  registryUrl: string = 'http://localhost:5000/composer',
40
- upstreamConfig?: IProtocolUpstreamConfig
40
+ upstreamProvider?: IUpstreamProvider
41
41
  ) {
42
42
  super();
43
43
  this.storage = storage;
44
44
  this.authManager = authManager;
45
45
  this.basePath = basePath;
46
46
  this.registryUrl = registryUrl;
47
+ this.upstreamProvider = upstreamProvider || null;
48
+ }
47
49
 
48
- // Initialize upstream if configured
49
- if (upstreamConfig?.enabled) {
50
- this.upstream = new ComposerUpstream(upstreamConfig);
50
+ /**
51
+ * Extract scope from Composer package name.
52
+ * For Composer, vendor is the scope.
53
+ * @example "symfony" from "symfony/console"
54
+ */
55
+ private extractScope(vendorPackage: string): string | null {
56
+ const slashIndex = vendorPackage.indexOf('/');
57
+ if (slashIndex > 0) {
58
+ return vendorPackage.substring(0, slashIndex);
51
59
  }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Get upstream for a specific request.
65
+ * Calls the provider to resolve upstream config dynamically.
66
+ */
67
+ private async getUpstreamForRequest(
68
+ resource: string,
69
+ resourceType: string,
70
+ method: string,
71
+ actor?: IRequestActor
72
+ ): Promise<ComposerUpstream | null> {
73
+ if (!this.upstreamProvider) return null;
74
+
75
+ const config = await this.upstreamProvider.resolveUpstreamConfig({
76
+ protocol: 'composer',
77
+ resource,
78
+ scope: this.extractScope(resource),
79
+ actor,
80
+ method,
81
+ resourceType,
82
+ });
83
+
84
+ if (!config?.enabled) return null;
85
+ return new ComposerUpstream(config);
52
86
  }
53
87
 
54
88
  /**
55
89
  * Clean up resources (timers, connections, etc.)
56
90
  */
57
91
  public destroy(): void {
58
- if (this.upstream) {
59
- this.upstream.stop();
60
- }
92
+ // No persistent upstream to clean up with dynamic provider
61
93
  }
62
94
 
63
95
  public async init(): Promise<void> {
@@ -96,6 +128,14 @@ export class ComposerRegistry extends BaseRegistry {
96
128
  }
97
129
  }
98
130
 
131
+ // Build actor from context and validated token
132
+ const actor: IRequestActor = {
133
+ ...context.actor,
134
+ userId: token?.userId,
135
+ ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
136
+ userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
137
+ };
138
+
99
139
  // Root packages.json
100
140
  if (path === '/packages.json' || path === '' || path === '/') {
101
141
  return this.handlePackagesJson();
@@ -106,7 +146,7 @@ export class ComposerRegistry extends BaseRegistry {
106
146
  if (metadataMatch) {
107
147
  const [, vendorPackage, devSuffix] = metadataMatch;
108
148
  const includeDev = !!devSuffix;
109
- return this.handlePackageMetadata(vendorPackage, includeDev, token);
149
+ return this.handlePackageMetadata(vendorPackage, includeDev, token, actor);
110
150
  }
111
151
 
112
152
  // Package list: /packages/list.json?filter=vendor/*
@@ -176,26 +216,30 @@ export class ComposerRegistry extends BaseRegistry {
176
216
  private async handlePackageMetadata(
177
217
  vendorPackage: string,
178
218
  includeDev: boolean,
179
- token: IAuthToken | null
219
+ token: IAuthToken | null,
220
+ actor?: IRequestActor
180
221
  ): Promise<IResponse> {
181
222
  // Read operations are public, no authentication required
182
223
  let metadata = await this.storage.getComposerPackageMetadata(vendorPackage);
183
224
 
184
225
  // Try upstream if not found locally
185
- if (!metadata && this.upstream) {
186
- const [vendor, packageName] = vendorPackage.split('/');
187
- if (vendor && packageName) {
188
- const upstreamMetadata = includeDev
189
- ? await this.upstream.fetchPackageDevMetadata(vendor, packageName)
190
- : await this.upstream.fetchPackageMetadata(vendor, packageName);
191
-
192
- if (upstreamMetadata && upstreamMetadata.packages) {
193
- // Store upstream metadata locally
194
- metadata = {
195
- packages: upstreamMetadata.packages,
196
- lastModified: new Date().toUTCString(),
197
- };
198
- await this.storage.putComposerPackageMetadata(vendorPackage, metadata);
226
+ if (!metadata) {
227
+ const upstream = await this.getUpstreamForRequest(vendorPackage, 'metadata', 'GET', actor);
228
+ if (upstream) {
229
+ const [vendor, packageName] = vendorPackage.split('/');
230
+ if (vendor && packageName) {
231
+ const upstreamMetadata = includeDev
232
+ ? await upstream.fetchPackageDevMetadata(vendor, packageName)
233
+ : await upstream.fetchPackageMetadata(vendor, packageName);
234
+
235
+ if (upstreamMetadata && upstreamMetadata.packages) {
236
+ // Store upstream metadata locally
237
+ metadata = {
238
+ packages: upstreamMetadata.packages,
239
+ lastModified: new Date().toUTCString(),
240
+ };
241
+ await this.storage.putComposerPackageMetadata(vendorPackage, metadata);
242
+ }
199
243
  }
200
244
  }
201
245
  }
@@ -258,9 +302,9 @@ export class ComposerRegistry extends BaseRegistry {
258
302
  token: IAuthToken | null
259
303
  ): Promise<IResponse> {
260
304
  // Read operations are public, no authentication required
261
- const zipData = await this.storage.getComposerPackageZip(vendorPackage, reference);
305
+ const streamResult = await this.storage.getComposerPackageZipStream(vendorPackage, reference);
262
306
 
263
- if (!zipData) {
307
+ if (!streamResult) {
264
308
  return {
265
309
  status: 404,
266
310
  headers: {},
@@ -272,10 +316,10 @@ export class ComposerRegistry extends BaseRegistry {
272
316
  status: 200,
273
317
  headers: {
274
318
  'Content-Type': 'application/zip',
275
- 'Content-Length': zipData.length.toString(),
319
+ 'Content-Length': streamResult.size.toString(),
276
320
  'Content-Disposition': `attachment; filename="${reference}.zip"`,
277
321
  },
278
- body: zipData,
322
+ body: streamResult.stream,
279
323
  };
280
324
  }
281
325