@push.rocks/smartregistry 2.5.0 → 2.8.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.
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 +104 -48
  19. package/dist_ts/oci/classes.ociregistry.d.ts +19 -3
  20. package/dist_ts/oci/classes.ociregistry.js +186 -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 +3 -3
  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 +118 -49
  44. package/ts/oci/classes.ociregistry.ts +205 -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 +2 -2
  50. package/ts/upstream/classes.upstreamcache.ts +1 -1
  51. package/ts/upstream/interfaces.upstream.ts +82 -1
  52. package/npmextra.json +0 -18
@@ -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 { isBinaryData, toBuffer } from '../core/helpers.buffer.js';
8
8
  import type {
9
9
  IPypiPackageMetadata,
@@ -24,20 +24,21 @@ export class PypiRegistry extends BaseRegistry {
24
24
  private basePath: string = '/pypi';
25
25
  private registryUrl: string;
26
26
  private logger: Smartlog;
27
- private upstream: PypiUpstream | null = null;
27
+ private upstreamProvider: IUpstreamProvider | null = null;
28
28
 
29
29
  constructor(
30
30
  storage: RegistryStorage,
31
31
  authManager: AuthManager,
32
32
  basePath: string = '/pypi',
33
33
  registryUrl: string = 'http://localhost:5000',
34
- upstreamConfig?: IProtocolUpstreamConfig
34
+ upstreamProvider?: IUpstreamProvider
35
35
  ) {
36
36
  super();
37
37
  this.storage = storage;
38
38
  this.authManager = authManager;
39
39
  this.basePath = basePath;
40
40
  this.registryUrl = registryUrl;
41
+ this.upstreamProvider = upstreamProvider || null;
41
42
 
42
43
  // Initialize logger
43
44
  this.logger = new Smartlog({
@@ -51,20 +52,38 @@ export class PypiRegistry extends BaseRegistry {
51
52
  }
52
53
  });
53
54
  this.logger.enableConsole();
55
+ }
54
56
 
55
- // Initialize upstream if configured
56
- if (upstreamConfig?.enabled) {
57
- this.upstream = new PypiUpstream(upstreamConfig, registryUrl, this.logger);
58
- }
57
+ /**
58
+ * Get upstream for a specific request.
59
+ * Calls the provider to resolve upstream config dynamically.
60
+ */
61
+ private async getUpstreamForRequest(
62
+ resource: string,
63
+ resourceType: string,
64
+ method: string,
65
+ actor?: IRequestActor
66
+ ): Promise<PypiUpstream | null> {
67
+ if (!this.upstreamProvider) return null;
68
+
69
+ const config = await this.upstreamProvider.resolveUpstreamConfig({
70
+ protocol: 'pypi',
71
+ resource,
72
+ scope: resource, // For PyPI, package name is the scope
73
+ actor,
74
+ method,
75
+ resourceType,
76
+ });
77
+
78
+ if (!config?.enabled) return null;
79
+ return new PypiUpstream(config, this.registryUrl, this.logger);
59
80
  }
60
81
 
61
82
  /**
62
83
  * Clean up resources (timers, connections, etc.)
63
84
  */
64
85
  public destroy(): void {
65
- if (this.upstream) {
66
- this.upstream.stop();
67
- }
86
+ // No persistent upstream to clean up with dynamic provider
68
87
  }
69
88
 
70
89
  public async init(): Promise<void> {
@@ -84,15 +103,23 @@ export class PypiRegistry extends BaseRegistry {
84
103
  public async handleRequest(context: IRequestContext): Promise<IResponse> {
85
104
  let path = context.path.replace(this.basePath, '');
86
105
 
106
+ // Extract token (Basic Auth or Bearer)
107
+ const token = await this.extractToken(context);
108
+
109
+ // Build actor from context and validated token
110
+ const actor: IRequestActor = {
111
+ ...context.actor,
112
+ userId: token?.userId,
113
+ ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
114
+ userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
115
+ };
116
+
87
117
  // Also handle /simple path prefix
88
118
  if (path.startsWith('/simple')) {
89
119
  path = path.replace('/simple', '');
90
- return this.handleSimpleRequest(path, context);
120
+ return this.handleSimpleRequest(path, context, actor);
91
121
  }
92
122
 
93
- // Extract token (Basic Auth or Bearer)
94
- const token = await this.extractToken(context);
95
-
96
123
  this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
97
124
  method: context.method,
98
125
  path,
@@ -119,7 +146,7 @@ export class PypiRegistry extends BaseRegistry {
119
146
  // Package file download: GET /packages/{package}/{filename}
120
147
  const downloadMatch = path.match(/^\/packages\/([^\/]+)\/(.+)$/);
121
148
  if (downloadMatch && context.method === 'GET') {
122
- return this.handleDownload(downloadMatch[1], downloadMatch[2]);
149
+ return this.handleDownload(downloadMatch[1], downloadMatch[2], actor);
123
150
  }
124
151
 
125
152
  // Delete package: DELETE /packages/{package}
@@ -156,7 +183,7 @@ export class PypiRegistry extends BaseRegistry {
156
183
  /**
157
184
  * Handle Simple API requests (PEP 503 HTML or PEP 691 JSON)
158
185
  */
159
- private async handleSimpleRequest(path: string, context: IRequestContext): Promise<IResponse> {
186
+ private async handleSimpleRequest(path: string, context: IRequestContext, actor?: IRequestActor): Promise<IResponse> {
160
187
  // Ensure path ends with / (PEP 503 requirement)
161
188
  if (!path.endsWith('/') && !path.includes('.')) {
162
189
  return {
@@ -174,7 +201,7 @@ export class PypiRegistry extends BaseRegistry {
174
201
  // Package index: /simple/{package}/
175
202
  const packageMatch = path.match(/^\/([^\/]+)\/$/);
176
203
  if (packageMatch) {
177
- return this.handleSimplePackage(packageMatch[1], context);
204
+ return this.handleSimplePackage(packageMatch[1], context, actor);
178
205
  }
179
206
 
180
207
  return {
@@ -228,46 +255,49 @@ export class PypiRegistry extends BaseRegistry {
228
255
  * Handle Simple API package index
229
256
  * Returns HTML (PEP 503) or JSON (PEP 691) based on Accept header
230
257
  */
231
- private async handleSimplePackage(packageName: string, context: IRequestContext): Promise<IResponse> {
258
+ private async handleSimplePackage(packageName: string, context: IRequestContext, actor?: IRequestActor): Promise<IResponse> {
232
259
  const normalized = helpers.normalizePypiPackageName(packageName);
233
260
 
234
261
  // Get package metadata
235
262
  let metadata = await this.storage.getPypiPackageMetadata(normalized);
236
263
 
237
264
  // Try upstream if not found locally
238
- if (!metadata && this.upstream) {
239
- const upstreamHtml = await this.upstream.fetchSimplePackage(normalized);
240
- if (upstreamHtml) {
241
- // Parse the HTML to extract file information and cache it
242
- // For now, just return the upstream HTML directly (caching can be improved later)
243
- const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
244
- const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
245
- acceptHeader.includes('json');
246
-
247
- if (preferJson) {
248
- // Try to get JSON format from upstream
249
- const upstreamJson = await this.upstream.fetchPackageJson(normalized);
250
- if (upstreamJson) {
251
- return {
252
- status: 200,
253
- headers: {
254
- 'Content-Type': 'application/vnd.pypi.simple.v1+json',
255
- 'Cache-Control': 'public, max-age=300'
256
- },
257
- body: upstreamJson,
258
- };
265
+ if (!metadata) {
266
+ const upstream = await this.getUpstreamForRequest(normalized, 'simple', 'GET', actor);
267
+ if (upstream) {
268
+ const upstreamHtml = await upstream.fetchSimplePackage(normalized);
269
+ if (upstreamHtml) {
270
+ // Parse the HTML to extract file information and cache it
271
+ // For now, just return the upstream HTML directly (caching can be improved later)
272
+ const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
273
+ const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
274
+ acceptHeader.includes('json');
275
+
276
+ if (preferJson) {
277
+ // Try to get JSON format from upstream
278
+ const upstreamJson = await upstream.fetchPackageJson(normalized);
279
+ if (upstreamJson) {
280
+ return {
281
+ status: 200,
282
+ headers: {
283
+ 'Content-Type': 'application/vnd.pypi.simple.v1+json',
284
+ 'Cache-Control': 'public, max-age=300'
285
+ },
286
+ body: upstreamJson,
287
+ };
288
+ }
259
289
  }
260
- }
261
290
 
262
- // Return HTML format
263
- return {
264
- status: 200,
265
- headers: {
266
- 'Content-Type': 'text/html; charset=utf-8',
267
- 'Cache-Control': 'public, max-age=300'
268
- },
269
- body: upstreamHtml,
270
- };
291
+ // Return HTML format
292
+ return {
293
+ status: 200,
294
+ headers: {
295
+ 'Content-Type': 'text/html; charset=utf-8',
296
+ 'Cache-Control': 'public, max-age=300'
297
+ },
298
+ body: upstreamHtml,
299
+ };
300
+ }
271
301
  }
272
302
  }
273
303
 
@@ -503,13 +533,29 @@ export class PypiRegistry extends BaseRegistry {
503
533
  /**
504
534
  * Handle package download
505
535
  */
506
- private async handleDownload(packageName: string, filename: string): Promise<IResponse> {
536
+ private async handleDownload(packageName: string, filename: string, actor?: IRequestActor): Promise<IResponse> {
507
537
  const normalized = helpers.normalizePypiPackageName(packageName);
508
- let fileData = await this.storage.getPypiPackageFile(normalized, filename);
538
+
539
+ // Try streaming from local storage first
540
+ const streamResult = await this.storage.getPypiPackageFileStream(normalized, filename);
541
+
542
+ if (streamResult) {
543
+ return {
544
+ status: 200,
545
+ headers: {
546
+ 'Content-Type': 'application/octet-stream',
547
+ 'Content-Disposition': `attachment; filename="${filename}"`,
548
+ 'Content-Length': streamResult.size.toString()
549
+ },
550
+ body: streamResult.stream,
551
+ };
552
+ }
509
553
 
510
554
  // Try upstream if not found locally
511
- if (!fileData && this.upstream) {
512
- fileData = await this.upstream.fetchPackageFile(normalized, filename);
555
+ let fileData: Buffer | null = null;
556
+ const upstream = await this.getUpstreamForRequest(normalized, 'file', 'GET', actor);
557
+ if (upstream) {
558
+ fileData = await upstream.fetchPackageFile(normalized, filename);
513
559
  if (fileData) {
514
560
  // Cache locally
515
561
  await this.storage.putPypiPackageFile(normalized, filename, fileData);
@@ -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
  IRubyGemsMetadata,
9
9
  IRubyGemsVersionMetadata,
@@ -25,20 +25,21 @@ export class RubyGemsRegistry extends BaseRegistry {
25
25
  private basePath: string = '/rubygems';
26
26
  private registryUrl: string;
27
27
  private logger: Smartlog;
28
- private upstream: RubygemsUpstream | null = null;
28
+ private upstreamProvider: IUpstreamProvider | null = null;
29
29
 
30
30
  constructor(
31
31
  storage: RegistryStorage,
32
32
  authManager: AuthManager,
33
33
  basePath: string = '/rubygems',
34
34
  registryUrl: string = 'http://localhost:5000/rubygems',
35
- upstreamConfig?: IProtocolUpstreamConfig
35
+ upstreamProvider?: IUpstreamProvider
36
36
  ) {
37
37
  super();
38
38
  this.storage = storage;
39
39
  this.authManager = authManager;
40
40
  this.basePath = basePath;
41
41
  this.registryUrl = registryUrl;
42
+ this.upstreamProvider = upstreamProvider || null;
42
43
 
43
44
  // Initialize logger
44
45
  this.logger = new Smartlog({
@@ -52,20 +53,38 @@ export class RubyGemsRegistry extends BaseRegistry {
52
53
  }
53
54
  });
54
55
  this.logger.enableConsole();
56
+ }
55
57
 
56
- // Initialize upstream if configured
57
- if (upstreamConfig?.enabled) {
58
- this.upstream = new RubygemsUpstream(upstreamConfig, this.logger);
59
- }
58
+ /**
59
+ * Get upstream for a specific request.
60
+ * Calls the provider to resolve upstream config dynamically.
61
+ */
62
+ private async getUpstreamForRequest(
63
+ resource: string,
64
+ resourceType: string,
65
+ method: string,
66
+ actor?: IRequestActor
67
+ ): Promise<RubygemsUpstream | null> {
68
+ if (!this.upstreamProvider) return null;
69
+
70
+ const config = await this.upstreamProvider.resolveUpstreamConfig({
71
+ protocol: 'rubygems',
72
+ resource,
73
+ scope: resource, // gem name is the scope
74
+ actor,
75
+ method,
76
+ resourceType,
77
+ });
78
+
79
+ if (!config?.enabled) return null;
80
+ return new RubygemsUpstream(config, this.logger);
60
81
  }
61
82
 
62
83
  /**
63
84
  * Clean up resources (timers, connections, etc.)
64
85
  */
65
86
  public destroy(): void {
66
- if (this.upstream) {
67
- this.upstream.stop();
68
- }
87
+ // No persistent upstream to clean up with dynamic provider
69
88
  }
70
89
 
71
90
  public async init(): Promise<void> {
@@ -95,6 +114,14 @@ export class RubyGemsRegistry extends BaseRegistry {
95
114
  // Extract token (Authorization header)
96
115
  const token = await this.extractToken(context);
97
116
 
117
+ // Build actor from context and validated token
118
+ const actor: IRequestActor = {
119
+ ...context.actor,
120
+ userId: token?.userId,
121
+ ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
122
+ userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
123
+ };
124
+
98
125
  this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
99
126
  method: context.method,
100
127
  path,
@@ -113,13 +140,13 @@ export class RubyGemsRegistry extends BaseRegistry {
113
140
  // Info file: GET /info/{gem}
114
141
  const infoMatch = path.match(/^\/info\/([^\/]+)$/);
115
142
  if (infoMatch && context.method === 'GET') {
116
- return this.handleInfoFile(infoMatch[1]);
143
+ return this.handleInfoFile(infoMatch[1], actor);
117
144
  }
118
145
 
119
146
  // Gem download: GET /gems/{gem}-{version}[-{platform}].gem
120
147
  const downloadMatch = path.match(/^\/gems\/(.+\.gem)$/);
121
148
  if (downloadMatch && context.method === 'GET') {
122
- return this.handleDownload(downloadMatch[1]);
149
+ return this.handleDownload(downloadMatch[1], actor);
123
150
  }
124
151
 
125
152
  // Legacy specs endpoints (Marshal format)
@@ -232,16 +259,19 @@ export class RubyGemsRegistry extends BaseRegistry {
232
259
  /**
233
260
  * Handle /info/{gem} endpoint (Compact Index)
234
261
  */
235
- private async handleInfoFile(gemName: string): Promise<IResponse> {
262
+ private async handleInfoFile(gemName: string, actor?: IRequestActor): Promise<IResponse> {
236
263
  let content = await this.storage.getRubyGemsInfo(gemName);
237
264
 
238
265
  // Try upstream if not found locally
239
- if (!content && this.upstream) {
240
- const upstreamInfo = await this.upstream.fetchInfo(gemName);
241
- if (upstreamInfo) {
242
- // Cache locally
243
- await this.storage.putRubyGemsInfo(gemName, upstreamInfo);
244
- content = upstreamInfo;
266
+ if (!content) {
267
+ const upstream = await this.getUpstreamForRequest(gemName, 'info', 'GET', actor);
268
+ if (upstream) {
269
+ const upstreamInfo = await upstream.fetchInfo(gemName);
270
+ if (upstreamInfo) {
271
+ // Cache locally
272
+ await this.storage.putRubyGemsInfo(gemName, upstreamInfo);
273
+ content = upstreamInfo;
274
+ }
245
275
  }
246
276
  }
247
277
 
@@ -267,21 +297,36 @@ export class RubyGemsRegistry extends BaseRegistry {
267
297
  /**
268
298
  * Handle gem file download
269
299
  */
270
- private async handleDownload(filename: string): Promise<IResponse> {
300
+ private async handleDownload(filename: string, actor?: IRequestActor): Promise<IResponse> {
271
301
  const parsed = helpers.parseGemFilename(filename);
272
302
  if (!parsed) {
273
303
  return this.errorResponse(400, 'Invalid gem filename');
274
304
  }
275
305
 
276
- let gemData = await this.storage.getRubyGemsGem(
306
+ // Try streaming from local storage first
307
+ const streamResult = await this.storage.getRubyGemsGemStream(
277
308
  parsed.name,
278
309
  parsed.version,
279
310
  parsed.platform
280
311
  );
281
312
 
313
+ if (streamResult) {
314
+ return {
315
+ status: 200,
316
+ headers: {
317
+ 'Content-Type': 'application/octet-stream',
318
+ 'Content-Disposition': `attachment; filename="${filename}"`,
319
+ 'Content-Length': streamResult.size.toString()
320
+ },
321
+ body: streamResult.stream,
322
+ };
323
+ }
324
+
282
325
  // Try upstream if not found locally
283
- if (!gemData && this.upstream) {
284
- gemData = await this.upstream.fetchGem(parsed.name, parsed.version);
326
+ let gemData: Buffer | null = null;
327
+ const upstream = await this.getUpstreamForRequest(parsed.name, 'gem', 'GET', actor);
328
+ if (upstream) {
329
+ gemData = await upstream.fetchGem(parsed.name, parsed.version);
285
330
  if (gemData) {
286
331
  // Cache locally
287
332
  await this.storage.putRubyGemsGem(parsed.name, parsed.version, gemData, parsed.platform);
@@ -427,7 +427,7 @@ export async function extractGemMetadata(gemData: Buffer): Promise<{
427
427
  // Step 2: Decompress the gzipped metadata
428
428
  const gzipTools = new plugins.smartarchive.GzipTools();
429
429
  const metadataYaml = await gzipTools.decompress(metadataFile.contentBuffer);
430
- const yamlContent = metadataYaml.toString('utf-8');
430
+ const yamlContent = Buffer.from(metadataYaml).toString('utf-8');
431
431
 
432
432
  // Step 3: Parse the YAML to extract name, version, platform
433
433
  // Look for name: field in YAML
@@ -503,7 +503,7 @@ export async function generateSpecsGz(specs: Array<[string, string, string]>): P
503
503
  }
504
504
 
505
505
  const uncompressed = Buffer.concat(parts);
506
- return gzipTools.compress(uncompressed);
506
+ return Buffer.from(await gzipTools.compress(uncompressed));
507
507
  }
508
508
 
509
509
  /**
@@ -105,7 +105,7 @@ export class UpstreamCache {
105
105
 
106
106
  // If not in memory and we have storage, check S3
107
107
  if (!entry && this.storage) {
108
- entry = await this.loadFromStorage(key);
108
+ entry = (await this.loadFromStorage(key)) ?? undefined;
109
109
  if (entry) {
110
110
  // Promote to memory cache
111
111
  this.memoryCache.set(key, entry);
@@ -1,4 +1,4 @@
1
- import type { TRegistryProtocol } from '../core/interfaces.core.js';
1
+ import type { TRegistryProtocol, IRequestActor } from '../core/interfaces.core.js';
2
2
 
3
3
  /**
4
4
  * Scope rule for routing requests to specific upstreams.
@@ -86,6 +86,8 @@ export interface IUpstreamRegistryConfig {
86
86
  cache?: Partial<IUpstreamCacheConfig>;
87
87
  /** Resilience configuration overrides */
88
88
  resilience?: Partial<IUpstreamResilienceConfig>;
89
+ /** API path prefix for OCI registries (default: /v2). Useful for registries behind reverse proxies. */
90
+ apiPrefix?: string;
89
91
  }
90
92
 
91
93
  /**
@@ -146,6 +148,8 @@ export interface IUpstreamFetchContext {
146
148
  headers: Record<string, string>;
147
149
  /** Query parameters */
148
150
  query: Record<string, string>;
151
+ /** Actor performing the request (for cache key isolation) */
152
+ actor?: IRequestActor;
149
153
  }
150
154
 
151
155
  /**
@@ -193,3 +197,80 @@ export const DEFAULT_RESILIENCE_CONFIG: IUpstreamResilienceConfig = {
193
197
  circuitBreakerThreshold: 5,
194
198
  circuitBreakerResetMs: 30000,
195
199
  };
200
+
201
+ // ============================================================================
202
+ // Upstream Provider Interfaces
203
+ // ============================================================================
204
+
205
+ /**
206
+ * Context for resolving upstream configuration.
207
+ * Passed to IUpstreamProvider per-request to enable dynamic upstream routing.
208
+ */
209
+ export interface IUpstreamResolutionContext {
210
+ /** Protocol being accessed */
211
+ protocol: TRegistryProtocol;
212
+ /** Resource identifier (package name, repository, coordinates, etc.) */
213
+ resource: string;
214
+ /** Extracted scope (e.g., "company" from "@company/pkg", "myorg" from "myorg/image") */
215
+ scope: string | null;
216
+ /** Actor performing the request */
217
+ actor?: IRequestActor;
218
+ /** HTTP method */
219
+ method: string;
220
+ /** Resource type (packument, tarball, manifest, blob, etc.) */
221
+ resourceType: string;
222
+ }
223
+
224
+ /**
225
+ * Dynamic upstream configuration provider.
226
+ * Implement this interface to provide per-request upstream routing
227
+ * based on actor context (user, organization, etc.)
228
+ *
229
+ * @example
230
+ * ```typescript
231
+ * class OrgUpstreamProvider implements IUpstreamProvider {
232
+ * constructor(private db: Database) {}
233
+ *
234
+ * async resolveUpstreamConfig(ctx: IUpstreamResolutionContext) {
235
+ * if (ctx.actor?.orgId) {
236
+ * const orgConfig = await this.db.getOrgUpstream(ctx.actor.orgId, ctx.protocol);
237
+ * if (orgConfig) return orgConfig;
238
+ * }
239
+ * return this.db.getDefaultUpstream(ctx.protocol);
240
+ * }
241
+ * }
242
+ * ```
243
+ */
244
+ export interface IUpstreamProvider {
245
+ /** Optional initialization */
246
+ init?(): Promise<void>;
247
+
248
+ /**
249
+ * Resolve upstream configuration for a request.
250
+ * @param context - Information about the current request
251
+ * @returns Upstream config to use, or null to skip upstream lookup
252
+ */
253
+ resolveUpstreamConfig(context: IUpstreamResolutionContext): Promise<IProtocolUpstreamConfig | null>;
254
+ }
255
+
256
+ /**
257
+ * Static upstream provider for simple configurations.
258
+ * Use this when you have fixed upstream registries that don't change per-request.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * const provider = new StaticUpstreamProvider({
263
+ * npm: {
264
+ * enabled: true,
265
+ * upstreams: [{ id: 'npmjs', url: 'https://registry.npmjs.org', priority: 1, enabled: true, auth: { type: 'none' } }],
266
+ * },
267
+ * });
268
+ * ```
269
+ */
270
+ export class StaticUpstreamProvider implements IUpstreamProvider {
271
+ constructor(private configs: Partial<Record<TRegistryProtocol, IProtocolUpstreamConfig>>) {}
272
+
273
+ async resolveUpstreamConfig(ctx: IUpstreamResolutionContext): Promise<IProtocolUpstreamConfig | null> {
274
+ return this.configs[ctx.protocol] ?? null;
275
+ }
276
+ }
package/npmextra.json DELETED
@@ -1,18 +0,0 @@
1
- {
2
- "gitzone": {
3
- "projectType": "npm",
4
- "module": {
5
- "githost": "code.foss.global",
6
- "gitscope": "push.rocks",
7
- "gitrepo": "smartregistry",
8
- "description": "a registry for npm modules and oci images",
9
- "npmPackagename": "@push.rocks/smartregistry",
10
- "license": "MIT",
11
- "projectDomain": "push.rocks"
12
- }
13
- },
14
- "npmci": {
15
- "npmGlobalTools": [],
16
- "npmAccessLevel": "public"
17
- }
18
- }