@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
@@ -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,38 @@ 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
+ // OCI spec: GET /v2/ is the version check endpoint
130
+ if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
85
131
  return this.handleVersionCheck();
86
132
  }
87
133
 
88
- // Manifest operations: /v2/{name}/manifests/{reference}
89
- const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
134
+ // Manifest operations: /{name}/manifests/{reference}
135
+ const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
90
136
  if (manifestMatch) {
91
137
  const [, name, reference] = manifestMatch;
92
138
  // Prefer rawBody for content-addressable operations to preserve exact bytes
93
139
  const bodyData = context.rawBody || context.body;
94
- return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers);
140
+ return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
95
141
  }
96
142
 
97
- // Blob operations: /v2/{name}/blobs/{digest}
98
- const blobMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
143
+ // Blob operations: /{name}/blobs/{digest}
144
+ const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
99
145
  if (blobMatch) {
100
146
  const [, name, digest] = blobMatch;
101
- return this.handleBlobRequest(context.method, name, digest, token, context.headers);
147
+ return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
102
148
  }
103
149
 
104
- // Blob upload operations: /v2/{name}/blobs/uploads/
105
- const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
150
+ // Blob upload operations: /{name}/blobs/uploads/
151
+ const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
106
152
  if (uploadInitMatch && context.method === 'POST') {
107
153
  const [, name] = uploadInitMatch;
108
154
  // Prefer rawBody for content-addressable operations to preserve exact bytes
@@ -110,22 +156,22 @@ export class OciRegistry extends BaseRegistry {
110
156
  return this.handleUploadInit(name, token, context.query, bodyData);
111
157
  }
112
158
 
113
- // Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
114
- const uploadMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
159
+ // Blob upload operations: /{name}/blobs/uploads/{uuid}
160
+ const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
115
161
  if (uploadMatch) {
116
162
  const [, name, uploadId] = uploadMatch;
117
163
  return this.handleUploadSession(context.method, uploadId, token, context);
118
164
  }
119
165
 
120
- // Tags list: /v2/{name}/tags/list
121
- const tagsMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
166
+ // Tags list: /{name}/tags/list
167
+ const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
122
168
  if (tagsMatch) {
123
169
  const [, name] = tagsMatch;
124
170
  return this.handleTagsList(name, token, context.query);
125
171
  }
126
172
 
127
- // Referrers: /v2/{name}/referrers/{digest}
128
- const referrersMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
173
+ // Referrers: /{name}/referrers/{digest}
174
+ const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
129
175
  if (referrersMatch) {
130
176
  const [, name, digest] = referrersMatch;
131
177
  return this.handleReferrers(name, digest, token, context.query);
@@ -168,11 +214,12 @@ export class OciRegistry extends BaseRegistry {
168
214
  reference: string,
169
215
  token: IAuthToken | null,
170
216
  body?: Buffer | any,
171
- headers?: Record<string, string>
217
+ headers?: Record<string, string>,
218
+ actor?: IRequestActor
172
219
  ): Promise<IResponse> {
173
220
  switch (method) {
174
221
  case 'GET':
175
- return this.getManifest(repository, reference, token, headers);
222
+ return this.getManifest(repository, reference, token, headers, actor);
176
223
  case 'HEAD':
177
224
  return this.headManifest(repository, reference, token);
178
225
  case 'PUT':
@@ -193,11 +240,12 @@ export class OciRegistry extends BaseRegistry {
193
240
  repository: string,
194
241
  digest: string,
195
242
  token: IAuthToken | null,
196
- headers: Record<string, string>
243
+ headers: Record<string, string>,
244
+ actor?: IRequestActor
197
245
  ): Promise<IResponse> {
198
246
  switch (method) {
199
247
  case 'GET':
200
- return this.getBlob(repository, digest, token, headers['range'] || headers['Range']);
248
+ return this.getBlob(repository, digest, token, headers['range'] || headers['Range'], actor);
201
249
  case 'HEAD':
202
250
  return this.headBlob(repository, digest, token);
203
251
  case 'DELETE':
@@ -243,7 +291,7 @@ export class OciRegistry extends BaseRegistry {
243
291
  return {
244
292
  status: 201,
245
293
  headers: {
246
- 'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`,
294
+ 'Location': `${this.basePath}/${repository}/blobs/${digest}`,
247
295
  'Docker-Content-Digest': digest,
248
296
  },
249
297
  body: null,
@@ -256,6 +304,8 @@ export class OciRegistry extends BaseRegistry {
256
304
  uploadId,
257
305
  repository,
258
306
  chunks: [],
307
+ chunkPaths: [],
308
+ chunkIndex: 0,
259
309
  totalSize: 0,
260
310
  createdAt: new Date(),
261
311
  lastActivity: new Date(),
@@ -266,7 +316,7 @@ export class OciRegistry extends BaseRegistry {
266
316
  return {
267
317
  status: 202,
268
318
  headers: {
269
- 'Location': `${this.basePath}/v2/${repository}/blobs/uploads/${uploadId}`,
319
+ 'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`,
270
320
  'Docker-Upload-UUID': uploadId,
271
321
  },
272
322
  body: null,
@@ -318,7 +368,8 @@ export class OciRegistry extends BaseRegistry {
318
368
  repository: string,
319
369
  reference: string,
320
370
  token: IAuthToken | null,
321
- headers?: Record<string, string>
371
+ headers?: Record<string, string>,
372
+ actor?: IRequestActor
322
373
  ): Promise<IResponse> {
323
374
  if (!await this.checkPermission(token, repository, 'pull')) {
324
375
  return this.createUnauthorizedResponse(repository, 'pull');
@@ -346,30 +397,33 @@ export class OciRegistry extends BaseRegistry {
346
397
  }
347
398
 
348
399
  // 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'));
400
+ if (!manifestData) {
401
+ const upstream = await this.getUpstreamForRequest(repository, 'manifest', 'GET', actor);
402
+ if (upstream) {
403
+ this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference });
404
+ const upstreamResult = await upstream.fetchManifest(repository, reference);
405
+ if (upstreamResult) {
406
+ manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8');
407
+ contentType = upstreamResult.contentType;
408
+ digest = upstreamResult.digest;
409
+
410
+ // Cache the manifest locally
411
+ await this.storage.putOciManifest(repository, digest, manifestData, contentType);
412
+
413
+ // If reference is a tag, update tags mapping
414
+ if (!reference.startsWith('sha256:')) {
415
+ const tags = await this.getTagsData(repository);
416
+ tags[reference] = digest;
417
+ const tagsPath = `oci/tags/${repository}/tags.json`;
418
+ await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8'));
419
+ }
420
+
421
+ this.logger.log('debug', 'getManifest: cached manifest locally', {
422
+ repository,
423
+ reference,
424
+ digest,
425
+ });
366
426
  }
367
-
368
- this.logger.log('debug', 'getManifest: cached manifest locally', {
369
- repository,
370
- reference,
371
- digest,
372
- });
373
427
  }
374
428
  }
375
429
 
@@ -477,7 +531,7 @@ export class OciRegistry extends BaseRegistry {
477
531
  return {
478
532
  status: 201,
479
533
  headers: {
480
- 'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`,
534
+ 'Location': `${this.basePath}/${repository}/manifests/${digest}`,
481
535
  'Docker-Content-Digest': digest,
482
536
  },
483
537
  body: null,
@@ -514,19 +568,33 @@ export class OciRegistry extends BaseRegistry {
514
568
  repository: string,
515
569
  digest: string,
516
570
  token: IAuthToken | null,
517
- range?: string
571
+ range?: string,
572
+ actor?: IRequestActor
518
573
  ): Promise<IResponse> {
519
574
  if (!await this.checkPermission(token, repository, 'pull')) {
520
575
  return this.createUnauthorizedResponse(repository, 'pull');
521
576
  }
522
577
 
523
- // Try local storage first
524
- let data = await this.storage.getOciBlob(digest);
578
+ // Try local storage first (streaming)
579
+ const streamResult = await this.storage.getOciBlobStream(digest);
580
+ if (streamResult) {
581
+ return {
582
+ status: 200,
583
+ headers: {
584
+ 'Content-Type': 'application/octet-stream',
585
+ 'Content-Length': streamResult.size.toString(),
586
+ 'Docker-Content-Digest': digest,
587
+ },
588
+ body: streamResult.stream,
589
+ };
590
+ }
525
591
 
526
592
  // If not found locally, try upstream
527
- if (!data && this.upstream) {
593
+ let data: Buffer | null = null;
594
+ const upstream = await this.getUpstreamForRequest(repository, 'blob', 'GET', actor);
595
+ if (upstream) {
528
596
  this.logger.log('debug', 'getBlob: fetching from upstream', { repository, digest });
529
- const upstreamBlob = await this.upstream.fetchBlob(repository, digest);
597
+ const upstreamBlob = await upstream.fetchBlob(repository, digest);
530
598
  if (upstreamBlob) {
531
599
  data = upstreamBlob;
532
600
  // Cache the blob locally (blobs are content-addressable and immutable)
@@ -566,17 +634,15 @@ export class OciRegistry extends BaseRegistry {
566
634
  return this.createUnauthorizedHeadResponse(repository, 'pull');
567
635
  }
568
636
 
569
- const exists = await this.storage.ociBlobExists(digest);
570
- if (!exists) {
637
+ const blobSize = await this.storage.getOciBlobSize(digest);
638
+ if (blobSize === null) {
571
639
  return { status: 404, headers: {}, body: null };
572
640
  }
573
641
 
574
- const blob = await this.storage.getOciBlob(digest);
575
-
576
642
  return {
577
643
  status: 200,
578
644
  headers: {
579
- 'Content-Length': blob ? blob.length.toString() : '0',
645
+ 'Content-Length': blobSize.toString(),
580
646
  'Docker-Content-Digest': digest,
581
647
  },
582
648
  body: null,
@@ -616,14 +682,19 @@ export class OciRegistry extends BaseRegistry {
616
682
  }
617
683
 
618
684
  const chunkData = this.toBuffer(data);
619
- session.chunks.push(chunkData);
685
+
686
+ // Write chunk to temp S3 object instead of accumulating in memory
687
+ const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
688
+ await this.storage.putObject(chunkPath, chunkData);
689
+ session.chunkPaths.push(chunkPath);
690
+ session.chunkIndex++;
620
691
  session.totalSize += chunkData.length;
621
692
  session.lastActivity = new Date();
622
693
 
623
694
  return {
624
695
  status: 202,
625
696
  headers: {
626
- 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
697
+ 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
627
698
  'Range': `0-${session.totalSize - 1}`,
628
699
  'Docker-Upload-UUID': uploadId,
629
700
  },
@@ -645,13 +716,52 @@ export class OciRegistry extends BaseRegistry {
645
716
  };
646
717
  }
647
718
 
648
- const chunks = [...session.chunks];
649
- if (finalData) chunks.push(this.toBuffer(finalData));
650
- const blobData = Buffer.concat(chunks);
719
+ // If there's final data in the PUT body, write it as the last chunk
720
+ if (finalData) {
721
+ const buf = this.toBuffer(finalData);
722
+ const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
723
+ await this.storage.putObject(chunkPath, buf);
724
+ session.chunkPaths.push(chunkPath);
725
+ session.chunkIndex++;
726
+ session.totalSize += buf.length;
727
+ }
728
+
729
+ // Create a ReadableStream that assembles all chunks from S3 sequentially
730
+ const chunkPaths = [...session.chunkPaths];
731
+ const storage = this.storage;
732
+ let chunkIdx = 0;
733
+ const assembledStream = new ReadableStream<Uint8Array>({
734
+ async pull(controller) {
735
+ if (chunkIdx >= chunkPaths.length) {
736
+ controller.close();
737
+ return;
738
+ }
739
+ const result = await storage.getObjectStream(chunkPaths[chunkIdx++]);
740
+ if (result) {
741
+ const reader = result.stream.getReader();
742
+ while (true) {
743
+ const { done, value } = await reader.read();
744
+ if (done) break;
745
+ if (value) controller.enqueue(value);
746
+ }
747
+ }
748
+ },
749
+ });
750
+
751
+ // Pipe through hash transform for incremental digest verification
752
+ const { transform: hashTransform, getDigest } = createHashTransform('sha256');
753
+ const hashedStream = assembledStream.pipeThrough(hashTransform);
651
754
 
652
- // Verify digest
653
- const calculatedDigest = await this.calculateDigest(blobData);
755
+ // Consume stream to buffer for S3 upload
756
+ // (AWS SDK PutObjectCommand requires known content-length for streams;
757
+ // the key win is chunks are NOT accumulated in memory during PATCH — they live in S3)
758
+ const blobData = await streamToBuffer(hashedStream);
759
+
760
+ // Verify digest before storing
761
+ const calculatedDigest = `sha256:${getDigest()}`;
654
762
  if (calculatedDigest !== digest) {
763
+ await this.cleanupUploadChunks(session);
764
+ this.uploadSessions.delete(uploadId);
655
765
  return {
656
766
  status: 400,
657
767
  headers: {},
@@ -659,19 +769,36 @@ export class OciRegistry extends BaseRegistry {
659
769
  };
660
770
  }
661
771
 
772
+ // Store verified blob
662
773
  await this.storage.putOciBlob(digest, blobData);
774
+
775
+ // Cleanup temp chunks and session
776
+ await this.cleanupUploadChunks(session);
663
777
  this.uploadSessions.delete(uploadId);
664
778
 
665
779
  return {
666
780
  status: 201,
667
781
  headers: {
668
- 'Location': `${this.basePath}/v2/${session.repository}/blobs/${digest}`,
782
+ 'Location': `${this.basePath}/${session.repository}/blobs/${digest}`,
669
783
  'Docker-Content-Digest': digest,
670
784
  },
671
785
  body: null,
672
786
  };
673
787
  }
674
788
 
789
+ /**
790
+ * Delete all temp S3 chunk objects for an upload session.
791
+ */
792
+ private async cleanupUploadChunks(session: IUploadSession): Promise<void> {
793
+ for (const chunkPath of session.chunkPaths) {
794
+ try {
795
+ await this.storage.deleteObject(chunkPath);
796
+ } catch {
797
+ // Best-effort cleanup
798
+ }
799
+ }
800
+ }
801
+
675
802
  private async getUploadStatus(uploadId: string): Promise<IResponse> {
676
803
  const session = this.uploadSessions.get(uploadId);
677
804
  if (!session) {
@@ -685,7 +812,7 @@ export class OciRegistry extends BaseRegistry {
685
812
  return {
686
813
  status: 204,
687
814
  headers: {
688
- 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
815
+ 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
689
816
  'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
690
817
  'Docker-Upload-UUID': uploadId,
691
818
  },
@@ -830,7 +957,7 @@ export class OciRegistry extends BaseRegistry {
830
957
  * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
831
958
  */
832
959
  private createUnauthorizedResponse(repository: string, action: string): IResponse {
833
- const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
960
+ const realm = this.ociTokens?.realm || `${this.basePath}/token`;
834
961
  const service = this.ociTokens?.service || 'registry';
835
962
  return {
836
963
  status: 401,
@@ -845,7 +972,7 @@ export class OciRegistry extends BaseRegistry {
845
972
  * Create an unauthorized HEAD response (no body per HTTP spec).
846
973
  */
847
974
  private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
848
- const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
975
+ const realm = this.ociTokens?.realm || `${this.basePath}/token`;
849
976
  const service = this.ociTokens?.service || 'registry';
850
977
  return {
851
978
  status: 401,
@@ -863,6 +990,8 @@ export class OciRegistry extends BaseRegistry {
863
990
 
864
991
  for (const [uploadId, session] of this.uploadSessions.entries()) {
865
992
  if (now.getTime() - session.lastActivity.getTime() > maxAge) {
993
+ // Clean up temp S3 chunks for stale sessions
994
+ this.cleanupUploadChunks(session).catch(() => {});
866
995
  this.uploadSessions.delete(uploadId);
867
996
  }
868
997
  }
@@ -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;