@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,9 @@ 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, IRegistryError } from '../core/interfaces.core.js';
6
- import type { IProtocolUpstreamConfig } from '../upstream/interfaces.upstream.js';
5
+ import type { IRequestContext, IResponse, IAuthToken, IRegistryError, IRequestActor } from '../core/interfaces.core.js';
6
+ import { createHashTransform, streamToBuffer } from '../core/helpers.stream.js';
7
+ import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
7
8
  import { OciUpstream } from './classes.ociupstream.js';
8
9
  import type {
9
10
  IUploadSession,
@@ -24,7 +25,7 @@ export class OciRegistry extends BaseRegistry {
24
25
  private basePath: string = '/oci';
25
26
  private cleanupInterval?: NodeJS.Timeout;
26
27
  private ociTokens?: { realm: string; service: string };
27
- private upstream: OciUpstream | null = null;
28
+ private upstreamProvider: IUpstreamProvider | null = null;
28
29
  private logger: Smartlog;
29
30
 
30
31
  constructor(
@@ -32,13 +33,14 @@ export class OciRegistry extends BaseRegistry {
32
33
  authManager: AuthManager,
33
34
  basePath: string = '/oci',
34
35
  ociTokens?: { realm: string; service: string },
35
- upstreamConfig?: IProtocolUpstreamConfig
36
+ upstreamProvider?: IUpstreamProvider
36
37
  ) {
37
38
  super();
38
39
  this.storage = storage;
39
40
  this.authManager = authManager;
40
41
  this.basePath = basePath;
41
42
  this.ociTokens = ociTokens;
43
+ this.upstreamProvider = upstreamProvider || null;
42
44
 
43
45
  // Initialize logger
44
46
  this.logger = new Smartlog({
@@ -53,15 +55,50 @@ export class OciRegistry extends BaseRegistry {
53
55
  });
54
56
  this.logger.enableConsole();
55
57
 
56
- // Initialize upstream if configured
57
- if (upstreamConfig?.enabled) {
58
- this.upstream = new OciUpstream(upstreamConfig, basePath, this.logger);
59
- this.logger.log('info', 'OCI upstream initialized', {
60
- upstreams: upstreamConfig.upstreams.map(u => u.name),
61
- });
58
+ if (upstreamProvider) {
59
+ this.logger.log('info', 'OCI upstream provider configured');
62
60
  }
63
61
  }
64
62
 
63
+ /**
64
+ * Extract scope from OCI repository name.
65
+ * @example "myorg/myimage" -> "myorg"
66
+ * @example "library/nginx" -> "library"
67
+ * @example "nginx" -> null
68
+ */
69
+ private extractScope(repository: string): string | null {
70
+ const slashIndex = repository.indexOf('/');
71
+ if (slashIndex > 0) {
72
+ return repository.substring(0, slashIndex);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Get upstream for a specific request.
79
+ * Calls the provider to resolve upstream config dynamically.
80
+ */
81
+ private async getUpstreamForRequest(
82
+ resource: string,
83
+ resourceType: string,
84
+ method: string,
85
+ actor?: IRequestActor
86
+ ): Promise<OciUpstream | null> {
87
+ if (!this.upstreamProvider) return null;
88
+
89
+ const config = await this.upstreamProvider.resolveUpstreamConfig({
90
+ protocol: 'oci',
91
+ resource,
92
+ scope: this.extractScope(resource),
93
+ actor,
94
+ method,
95
+ resourceType,
96
+ });
97
+
98
+ if (!config?.enabled) return null;
99
+ return new OciUpstream(config, this.basePath, this.logger);
100
+ }
101
+
65
102
  public async init(): Promise<void> {
66
103
  // Start cleanup of stale upload sessions
67
104
  this.startUploadSessionCleanup();
@@ -80,29 +117,37 @@ export class OciRegistry extends BaseRegistry {
80
117
  const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
81
118
  const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
82
119
 
120
+ // Build actor from context and validated token
121
+ const actor: IRequestActor = {
122
+ ...context.actor,
123
+ userId: token?.userId,
124
+ ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
125
+ userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
126
+ };
127
+
83
128
  // Route to appropriate handler
84
- if (path === '/v2/' || path === '/v2') {
129
+ if (path === '/' || path === '') {
85
130
  return this.handleVersionCheck();
86
131
  }
87
132
 
88
- // Manifest operations: /v2/{name}/manifests/{reference}
89
- const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
133
+ // Manifest operations: /{name}/manifests/{reference}
134
+ const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
90
135
  if (manifestMatch) {
91
136
  const [, name, reference] = manifestMatch;
92
137
  // Prefer rawBody for content-addressable operations to preserve exact bytes
93
138
  const bodyData = context.rawBody || context.body;
94
- return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers);
139
+ return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
95
140
  }
96
141
 
97
- // Blob operations: /v2/{name}/blobs/{digest}
98
- const blobMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
142
+ // Blob operations: /{name}/blobs/{digest}
143
+ const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
99
144
  if (blobMatch) {
100
145
  const [, name, digest] = blobMatch;
101
- return this.handleBlobRequest(context.method, name, digest, token, context.headers);
146
+ return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
102
147
  }
103
148
 
104
- // Blob upload operations: /v2/{name}/blobs/uploads/
105
- const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
149
+ // Blob upload operations: /{name}/blobs/uploads/
150
+ const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
106
151
  if (uploadInitMatch && context.method === 'POST') {
107
152
  const [, name] = uploadInitMatch;
108
153
  // Prefer rawBody for content-addressable operations to preserve exact bytes
@@ -110,22 +155,22 @@ export class OciRegistry extends BaseRegistry {
110
155
  return this.handleUploadInit(name, token, context.query, bodyData);
111
156
  }
112
157
 
113
- // Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
114
- const uploadMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
158
+ // Blob upload operations: /{name}/blobs/uploads/{uuid}
159
+ const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
115
160
  if (uploadMatch) {
116
161
  const [, name, uploadId] = uploadMatch;
117
162
  return this.handleUploadSession(context.method, uploadId, token, context);
118
163
  }
119
164
 
120
- // Tags list: /v2/{name}/tags/list
121
- const tagsMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
165
+ // Tags list: /{name}/tags/list
166
+ const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
122
167
  if (tagsMatch) {
123
168
  const [, name] = tagsMatch;
124
169
  return this.handleTagsList(name, token, context.query);
125
170
  }
126
171
 
127
- // Referrers: /v2/{name}/referrers/{digest}
128
- const referrersMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
172
+ // Referrers: /{name}/referrers/{digest}
173
+ const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
129
174
  if (referrersMatch) {
130
175
  const [, name, digest] = referrersMatch;
131
176
  return this.handleReferrers(name, digest, token, context.query);
@@ -168,11 +213,12 @@ export class OciRegistry extends BaseRegistry {
168
213
  reference: string,
169
214
  token: IAuthToken | null,
170
215
  body?: Buffer | any,
171
- headers?: Record<string, string>
216
+ headers?: Record<string, string>,
217
+ actor?: IRequestActor
172
218
  ): Promise<IResponse> {
173
219
  switch (method) {
174
220
  case 'GET':
175
- return this.getManifest(repository, reference, token, headers);
221
+ return this.getManifest(repository, reference, token, headers, actor);
176
222
  case 'HEAD':
177
223
  return this.headManifest(repository, reference, token);
178
224
  case 'PUT':
@@ -193,11 +239,12 @@ export class OciRegistry extends BaseRegistry {
193
239
  repository: string,
194
240
  digest: string,
195
241
  token: IAuthToken | null,
196
- headers: Record<string, string>
242
+ headers: Record<string, string>,
243
+ actor?: IRequestActor
197
244
  ): Promise<IResponse> {
198
245
  switch (method) {
199
246
  case 'GET':
200
- return this.getBlob(repository, digest, token, headers['range'] || headers['Range']);
247
+ return this.getBlob(repository, digest, token, headers['range'] || headers['Range'], actor);
201
248
  case 'HEAD':
202
249
  return this.headBlob(repository, digest, token);
203
250
  case 'DELETE':
@@ -243,7 +290,7 @@ export class OciRegistry extends BaseRegistry {
243
290
  return {
244
291
  status: 201,
245
292
  headers: {
246
- 'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`,
293
+ 'Location': `${this.basePath}/${repository}/blobs/${digest}`,
247
294
  'Docker-Content-Digest': digest,
248
295
  },
249
296
  body: null,
@@ -256,6 +303,8 @@ export class OciRegistry extends BaseRegistry {
256
303
  uploadId,
257
304
  repository,
258
305
  chunks: [],
306
+ chunkPaths: [],
307
+ chunkIndex: 0,
259
308
  totalSize: 0,
260
309
  createdAt: new Date(),
261
310
  lastActivity: new Date(),
@@ -266,7 +315,7 @@ export class OciRegistry extends BaseRegistry {
266
315
  return {
267
316
  status: 202,
268
317
  headers: {
269
- 'Location': `${this.basePath}/v2/${repository}/blobs/uploads/${uploadId}`,
318
+ 'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`,
270
319
  'Docker-Upload-UUID': uploadId,
271
320
  },
272
321
  body: null,
@@ -318,7 +367,8 @@ export class OciRegistry extends BaseRegistry {
318
367
  repository: string,
319
368
  reference: string,
320
369
  token: IAuthToken | null,
321
- headers?: Record<string, string>
370
+ headers?: Record<string, string>,
371
+ actor?: IRequestActor
322
372
  ): Promise<IResponse> {
323
373
  if (!await this.checkPermission(token, repository, 'pull')) {
324
374
  return this.createUnauthorizedResponse(repository, 'pull');
@@ -346,30 +396,33 @@ export class OciRegistry extends BaseRegistry {
346
396
  }
347
397
 
348
398
  // If not found locally, try upstream
349
- if (!manifestData && this.upstream) {
350
- this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference });
351
- const upstreamResult = await this.upstream.fetchManifest(repository, reference);
352
- if (upstreamResult) {
353
- manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8');
354
- contentType = upstreamResult.contentType;
355
- digest = upstreamResult.digest;
356
-
357
- // Cache the manifest locally
358
- await this.storage.putOciManifest(repository, digest, manifestData, contentType);
359
-
360
- // If reference is a tag, update tags mapping
361
- if (!reference.startsWith('sha256:')) {
362
- const tags = await this.getTagsData(repository);
363
- tags[reference] = digest;
364
- const tagsPath = `oci/tags/${repository}/tags.json`;
365
- await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8'));
399
+ if (!manifestData) {
400
+ const upstream = await this.getUpstreamForRequest(repository, 'manifest', 'GET', actor);
401
+ if (upstream) {
402
+ this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference });
403
+ const upstreamResult = await upstream.fetchManifest(repository, reference);
404
+ if (upstreamResult) {
405
+ manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8');
406
+ contentType = upstreamResult.contentType;
407
+ digest = upstreamResult.digest;
408
+
409
+ // Cache the manifest locally
410
+ await this.storage.putOciManifest(repository, digest, manifestData, contentType);
411
+
412
+ // If reference is a tag, update tags mapping
413
+ if (!reference.startsWith('sha256:')) {
414
+ const tags = await this.getTagsData(repository);
415
+ tags[reference] = digest;
416
+ const tagsPath = `oci/tags/${repository}/tags.json`;
417
+ await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8'));
418
+ }
419
+
420
+ this.logger.log('debug', 'getManifest: cached manifest locally', {
421
+ repository,
422
+ reference,
423
+ digest,
424
+ });
366
425
  }
367
-
368
- this.logger.log('debug', 'getManifest: cached manifest locally', {
369
- repository,
370
- reference,
371
- digest,
372
- });
373
426
  }
374
427
  }
375
428
 
@@ -477,7 +530,7 @@ export class OciRegistry extends BaseRegistry {
477
530
  return {
478
531
  status: 201,
479
532
  headers: {
480
- 'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`,
533
+ 'Location': `${this.basePath}/${repository}/manifests/${digest}`,
481
534
  'Docker-Content-Digest': digest,
482
535
  },
483
536
  body: null,
@@ -514,19 +567,33 @@ export class OciRegistry extends BaseRegistry {
514
567
  repository: string,
515
568
  digest: string,
516
569
  token: IAuthToken | null,
517
- range?: string
570
+ range?: string,
571
+ actor?: IRequestActor
518
572
  ): Promise<IResponse> {
519
573
  if (!await this.checkPermission(token, repository, 'pull')) {
520
574
  return this.createUnauthorizedResponse(repository, 'pull');
521
575
  }
522
576
 
523
- // Try local storage first
524
- let data = await this.storage.getOciBlob(digest);
577
+ // Try local storage first (streaming)
578
+ const streamResult = await this.storage.getOciBlobStream(digest);
579
+ if (streamResult) {
580
+ return {
581
+ status: 200,
582
+ headers: {
583
+ 'Content-Type': 'application/octet-stream',
584
+ 'Content-Length': streamResult.size.toString(),
585
+ 'Docker-Content-Digest': digest,
586
+ },
587
+ body: streamResult.stream,
588
+ };
589
+ }
525
590
 
526
591
  // If not found locally, try upstream
527
- if (!data && this.upstream) {
592
+ let data: Buffer | null = null;
593
+ const upstream = await this.getUpstreamForRequest(repository, 'blob', 'GET', actor);
594
+ if (upstream) {
528
595
  this.logger.log('debug', 'getBlob: fetching from upstream', { repository, digest });
529
- const upstreamBlob = await this.upstream.fetchBlob(repository, digest);
596
+ const upstreamBlob = await upstream.fetchBlob(repository, digest);
530
597
  if (upstreamBlob) {
531
598
  data = upstreamBlob;
532
599
  // Cache the blob locally (blobs are content-addressable and immutable)
@@ -566,17 +633,15 @@ export class OciRegistry extends BaseRegistry {
566
633
  return this.createUnauthorizedHeadResponse(repository, 'pull');
567
634
  }
568
635
 
569
- const exists = await this.storage.ociBlobExists(digest);
570
- if (!exists) {
636
+ const blobSize = await this.storage.getOciBlobSize(digest);
637
+ if (blobSize === null) {
571
638
  return { status: 404, headers: {}, body: null };
572
639
  }
573
640
 
574
- const blob = await this.storage.getOciBlob(digest);
575
-
576
641
  return {
577
642
  status: 200,
578
643
  headers: {
579
- 'Content-Length': blob ? blob.length.toString() : '0',
644
+ 'Content-Length': blobSize.toString(),
580
645
  'Docker-Content-Digest': digest,
581
646
  },
582
647
  body: null,
@@ -616,14 +681,19 @@ export class OciRegistry extends BaseRegistry {
616
681
  }
617
682
 
618
683
  const chunkData = this.toBuffer(data);
619
- session.chunks.push(chunkData);
684
+
685
+ // Write chunk to temp S3 object instead of accumulating in memory
686
+ const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
687
+ await this.storage.putObject(chunkPath, chunkData);
688
+ session.chunkPaths.push(chunkPath);
689
+ session.chunkIndex++;
620
690
  session.totalSize += chunkData.length;
621
691
  session.lastActivity = new Date();
622
692
 
623
693
  return {
624
694
  status: 202,
625
695
  headers: {
626
- 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
696
+ 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
627
697
  'Range': `0-${session.totalSize - 1}`,
628
698
  'Docker-Upload-UUID': uploadId,
629
699
  },
@@ -645,13 +715,52 @@ export class OciRegistry extends BaseRegistry {
645
715
  };
646
716
  }
647
717
 
648
- const chunks = [...session.chunks];
649
- if (finalData) chunks.push(this.toBuffer(finalData));
650
- const blobData = Buffer.concat(chunks);
718
+ // If there's final data in the PUT body, write it as the last chunk
719
+ if (finalData) {
720
+ const buf = this.toBuffer(finalData);
721
+ const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
722
+ await this.storage.putObject(chunkPath, buf);
723
+ session.chunkPaths.push(chunkPath);
724
+ session.chunkIndex++;
725
+ session.totalSize += buf.length;
726
+ }
727
+
728
+ // Create a ReadableStream that assembles all chunks from S3 sequentially
729
+ const chunkPaths = [...session.chunkPaths];
730
+ const storage = this.storage;
731
+ let chunkIdx = 0;
732
+ const assembledStream = new ReadableStream<Uint8Array>({
733
+ async pull(controller) {
734
+ if (chunkIdx >= chunkPaths.length) {
735
+ controller.close();
736
+ return;
737
+ }
738
+ const result = await storage.getObjectStream(chunkPaths[chunkIdx++]);
739
+ if (result) {
740
+ const reader = result.stream.getReader();
741
+ while (true) {
742
+ const { done, value } = await reader.read();
743
+ if (done) break;
744
+ if (value) controller.enqueue(value);
745
+ }
746
+ }
747
+ },
748
+ });
749
+
750
+ // Pipe through hash transform for incremental digest verification
751
+ const { transform: hashTransform, getDigest } = createHashTransform('sha256');
752
+ const hashedStream = assembledStream.pipeThrough(hashTransform);
651
753
 
652
- // Verify digest
653
- const calculatedDigest = await this.calculateDigest(blobData);
754
+ // Consume stream to buffer for S3 upload
755
+ // (AWS SDK PutObjectCommand requires known content-length for streams;
756
+ // the key win is chunks are NOT accumulated in memory during PATCH — they live in S3)
757
+ const blobData = await streamToBuffer(hashedStream);
758
+
759
+ // Verify digest before storing
760
+ const calculatedDigest = `sha256:${getDigest()}`;
654
761
  if (calculatedDigest !== digest) {
762
+ await this.cleanupUploadChunks(session);
763
+ this.uploadSessions.delete(uploadId);
655
764
  return {
656
765
  status: 400,
657
766
  headers: {},
@@ -659,19 +768,36 @@ export class OciRegistry extends BaseRegistry {
659
768
  };
660
769
  }
661
770
 
771
+ // Store verified blob
662
772
  await this.storage.putOciBlob(digest, blobData);
773
+
774
+ // Cleanup temp chunks and session
775
+ await this.cleanupUploadChunks(session);
663
776
  this.uploadSessions.delete(uploadId);
664
777
 
665
778
  return {
666
779
  status: 201,
667
780
  headers: {
668
- 'Location': `${this.basePath}/v2/${session.repository}/blobs/${digest}`,
781
+ 'Location': `${this.basePath}/${session.repository}/blobs/${digest}`,
669
782
  'Docker-Content-Digest': digest,
670
783
  },
671
784
  body: null,
672
785
  };
673
786
  }
674
787
 
788
+ /**
789
+ * Delete all temp S3 chunk objects for an upload session.
790
+ */
791
+ private async cleanupUploadChunks(session: IUploadSession): Promise<void> {
792
+ for (const chunkPath of session.chunkPaths) {
793
+ try {
794
+ await this.storage.deleteObject(chunkPath);
795
+ } catch {
796
+ // Best-effort cleanup
797
+ }
798
+ }
799
+ }
800
+
675
801
  private async getUploadStatus(uploadId: string): Promise<IResponse> {
676
802
  const session = this.uploadSessions.get(uploadId);
677
803
  if (!session) {
@@ -685,7 +811,7 @@ export class OciRegistry extends BaseRegistry {
685
811
  return {
686
812
  status: 204,
687
813
  headers: {
688
- 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
814
+ 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
689
815
  'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
690
816
  'Docker-Upload-UUID': uploadId,
691
817
  },
@@ -830,7 +956,7 @@ export class OciRegistry extends BaseRegistry {
830
956
  * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
831
957
  */
832
958
  private createUnauthorizedResponse(repository: string, action: string): IResponse {
833
- const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
959
+ const realm = this.ociTokens?.realm || `${this.basePath}/token`;
834
960
  const service = this.ociTokens?.service || 'registry';
835
961
  return {
836
962
  status: 401,
@@ -845,7 +971,7 @@ export class OciRegistry extends BaseRegistry {
845
971
  * Create an unauthorized HEAD response (no body per HTTP spec).
846
972
  */
847
973
  private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
848
- const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
974
+ const realm = this.ociTokens?.realm || `${this.basePath}/token`;
849
975
  const service = this.ociTokens?.service || 'registry';
850
976
  return {
851
977
  status: 401,
@@ -863,6 +989,8 @@ export class OciRegistry extends BaseRegistry {
863
989
 
864
990
  for (const [uploadId, session] of this.uploadSessions.entries()) {
865
991
  if (now.getTime() - session.lastActivity.getTime() > maxAge) {
992
+ // Clean up temp S3 chunks for stale sessions
993
+ this.cleanupUploadChunks(session).catch(() => {});
866
994
  this.uploadSessions.delete(uploadId);
867
995
  }
868
996
  }
@@ -24,13 +24,18 @@ export class OciUpstream extends BaseUpstream {
24
24
  /** Local registry base path for URL building */
25
25
  private readonly localBasePath: string;
26
26
 
27
+ /** API prefix for outbound OCI requests (default: /v2) */
28
+ private readonly apiPrefix: string;
29
+
27
30
  constructor(
28
31
  config: IProtocolUpstreamConfig,
29
32
  localBasePath: string = '/oci',
30
33
  logger?: plugins.smartlog.Smartlog,
34
+ apiPrefix: string = '/v2',
31
35
  ) {
32
36
  super(config, logger);
33
37
  this.localBasePath = localBasePath;
38
+ this.apiPrefix = apiPrefix;
34
39
  }
35
40
 
36
41
  /**
@@ -44,7 +49,7 @@ export class OciUpstream extends BaseUpstream {
44
49
  protocol: 'oci',
45
50
  resource: repository,
46
51
  resourceType: 'manifest',
47
- path: `/v2/${repository}/manifests/${reference}`,
52
+ path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
48
53
  method: 'GET',
49
54
  headers: {
50
55
  'accept': [
@@ -88,7 +93,7 @@ export class OciUpstream extends BaseUpstream {
88
93
  protocol: 'oci',
89
94
  resource: repository,
90
95
  resourceType: 'manifest',
91
- path: `/v2/${repository}/manifests/${reference}`,
96
+ path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
92
97
  method: 'HEAD',
93
98
  headers: {
94
99
  'accept': [
@@ -127,7 +132,7 @@ export class OciUpstream extends BaseUpstream {
127
132
  protocol: 'oci',
128
133
  resource: repository,
129
134
  resourceType: 'blob',
130
- path: `/v2/${repository}/blobs/${digest}`,
135
+ path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
131
136
  method: 'GET',
132
137
  headers: {
133
138
  'accept': 'application/octet-stream',
@@ -155,7 +160,7 @@ export class OciUpstream extends BaseUpstream {
155
160
  protocol: 'oci',
156
161
  resource: repository,
157
162
  resourceType: 'blob',
158
- path: `/v2/${repository}/blobs/${digest}`,
163
+ path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
159
164
  method: 'HEAD',
160
165
  headers: {},
161
166
  query: {},
@@ -189,7 +194,7 @@ export class OciUpstream extends BaseUpstream {
189
194
  protocol: 'oci',
190
195
  resource: repository,
191
196
  resourceType: 'tags',
192
- path: `/v2/${repository}/tags/list`,
197
+ path: `${this.apiPrefix}/${repository}/tags/list`,
193
198
  method: 'GET',
194
199
  headers: {
195
200
  'accept': 'application/json',
@@ -215,7 +220,8 @@ export class OciUpstream extends BaseUpstream {
215
220
 
216
221
  /**
217
222
  * Override URL building for OCI-specific handling.
218
- * OCI registries use /v2/ prefix and may require special handling for Docker Hub.
223
+ * OCI registries use a configurable API prefix (default /v2/) and may require
224
+ * special handling for Docker Hub.
219
225
  */
220
226
  protected buildUpstreamUrl(
221
227
  upstream: IUpstreamRegistryConfig,
@@ -228,16 +234,20 @@ export class OciUpstream extends BaseUpstream {
228
234
  baseUrl = baseUrl.slice(0, -1);
229
235
  }
230
236
 
237
+ // Use per-upstream apiPrefix if configured, otherwise use the instance default
238
+ const prefix = upstream.apiPrefix || this.apiPrefix;
239
+
231
240
  // Handle Docker Hub special case
232
241
  // Docker Hub uses registry-1.docker.io but library images need special handling
233
242
  if (baseUrl.includes('docker.io') || baseUrl.includes('registry-1.docker.io')) {
234
243
  // For library images (e.g., "nginx" -> "library/nginx")
235
- const pathParts = context.path.match(/^\/v2\/([^\/]+)\/(.+)$/);
244
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
245
+ const pathParts = context.path.match(new RegExp(`^${escapedPrefix}\\/([^\\/]+)\\/(.+)$`));
236
246
  if (pathParts) {
237
247
  const [, repository, rest] = pathParts;
238
248
  // If repository doesn't contain a slash, it's a library image
239
249
  if (!repository.includes('/')) {
240
- return `${baseUrl}/v2/library/${repository}/${rest}`;
250
+ return `${baseUrl}${prefix}/library/${repository}/${rest}`;
241
251
  }
242
252
  }
243
253
  }
@@ -62,6 +62,10 @@ export interface IUploadSession {
62
62
  uploadId: string;
63
63
  repository: string;
64
64
  chunks: Buffer[];
65
+ /** S3 paths to temp chunk objects (streaming mode) */
66
+ chunkPaths: string[];
67
+ /** Index counter for naming temp chunk objects */
68
+ chunkIndex: number;
65
69
  totalSize: number;
66
70
  createdAt: Date;
67
71
  lastActivity: Date;