@push.rocks/smartregistry 1.7.0 โ†’ 2.0.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.
package/readme.md CHANGED
@@ -1024,6 +1024,82 @@ pnpm run build
1024
1024
  pnpm test
1025
1025
  ```
1026
1026
 
1027
+ ## ๐Ÿงช Testing with smarts3
1028
+
1029
+ smartregistry works seamlessly with [@push.rocks/smarts3](https://code.foss.global/push.rocks/smarts3), a local S3-compatible server for testing. This allows you to test the registry without needing cloud credentials or external services.
1030
+
1031
+ ### Quick Start with smarts3
1032
+
1033
+ ```typescript
1034
+ import { Smarts3 } from '@push.rocks/smarts3';
1035
+ import { SmartRegistry } from '@push.rocks/smartregistry';
1036
+
1037
+ // Start local S3 server
1038
+ const s3Server = await Smarts3.createAndStart({
1039
+ server: { port: 3456 },
1040
+ storage: { cleanSlate: true },
1041
+ });
1042
+
1043
+ // Manually create IS3Descriptor matching smarts3 configuration
1044
+ // Note: smarts3 v5.1.0 doesn't properly expose getS3Descriptor() yet
1045
+ const s3Descriptor = {
1046
+ endpoint: 'localhost',
1047
+ port: 3456,
1048
+ accessKey: 'test',
1049
+ accessSecret: 'test',
1050
+ useSsl: false,
1051
+ region: 'us-east-1',
1052
+ };
1053
+
1054
+ // Create registry with smarts3 configuration
1055
+ const registry = new SmartRegistry({
1056
+ storage: {
1057
+ ...s3Descriptor,
1058
+ bucketName: 'my-test-registry',
1059
+ },
1060
+ auth: {
1061
+ jwtSecret: 'test-secret',
1062
+ tokenStore: 'memory',
1063
+ npmTokens: { enabled: true },
1064
+ ociTokens: {
1065
+ enabled: true,
1066
+ realm: 'https://auth.example.com/token',
1067
+ service: 'my-registry',
1068
+ },
1069
+ },
1070
+ npm: { enabled: true, basePath: '/npm' },
1071
+ oci: { enabled: true, basePath: '/oci' },
1072
+ pypi: { enabled: true, basePath: '/pypi' },
1073
+ cargo: { enabled: true, basePath: '/cargo' },
1074
+ });
1075
+
1076
+ await registry.init();
1077
+
1078
+ // Use registry...
1079
+ // Your tests here
1080
+
1081
+ // Cleanup
1082
+ await s3Server.stop();
1083
+ ```
1084
+
1085
+ ### Benefits of Testing with smarts3
1086
+
1087
+ - โœ… **Zero Setup** - No cloud credentials or external services needed
1088
+ - โœ… **Fast** - Local filesystem storage, no network latency
1089
+ - โœ… **Isolated** - Clean slate per test run, no shared state
1090
+ - โœ… **CI/CD Ready** - Works in automated pipelines without configuration
1091
+ - โœ… **Full Compatibility** - Implements S3 API, works with IS3Descriptor
1092
+
1093
+ ### Running Integration Tests
1094
+
1095
+ ```bash
1096
+ # Run smarts3 integration test
1097
+ pnpm exec tstest test/test.integration.smarts3.node.ts --verbose
1098
+
1099
+ # Run all tests (includes smarts3)
1100
+ pnpm test
1101
+ ```
1102
+
1027
1103
  ## License and Legal Information
1028
1104
 
1029
1105
  This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartregistry',
6
- version: '1.7.0',
6
+ version: '2.0.0',
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
  }
@@ -41,7 +41,7 @@ export class SmartRegistry {
41
41
 
42
42
  // Initialize OCI registry if enabled
43
43
  if (this.config.oci?.enabled) {
44
- const ociBasePath = this.config.oci.basePath || '/oci';
44
+ const ociBasePath = this.config.oci.basePath ?? '/oci';
45
45
  const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath);
46
46
  await ociRegistry.init();
47
47
  this.registries.set('oci', ociRegistry);
@@ -49,7 +49,7 @@ export class SmartRegistry {
49
49
 
50
50
  // Initialize NPM registry if enabled
51
51
  if (this.config.npm?.enabled) {
52
- const npmBasePath = this.config.npm.basePath || '/npm';
52
+ const npmBasePath = this.config.npm.basePath ?? '/npm';
53
53
  const registryUrl = `http://localhost:5000${npmBasePath}`; // TODO: Make configurable
54
54
  const npmRegistry = new NpmRegistry(this.storage, this.authManager, npmBasePath, registryUrl);
55
55
  await npmRegistry.init();
@@ -58,7 +58,7 @@ export class SmartRegistry {
58
58
 
59
59
  // Initialize Maven registry if enabled
60
60
  if (this.config.maven?.enabled) {
61
- const mavenBasePath = this.config.maven.basePath || '/maven';
61
+ const mavenBasePath = this.config.maven.basePath ?? '/maven';
62
62
  const registryUrl = `http://localhost:5000${mavenBasePath}`; // TODO: Make configurable
63
63
  const mavenRegistry = new MavenRegistry(this.storage, this.authManager, mavenBasePath, registryUrl);
64
64
  await mavenRegistry.init();
@@ -67,7 +67,7 @@ export class SmartRegistry {
67
67
 
68
68
  // Initialize Cargo registry if enabled
69
69
  if (this.config.cargo?.enabled) {
70
- const cargoBasePath = this.config.cargo.basePath || '/cargo';
70
+ const cargoBasePath = this.config.cargo.basePath ?? '/cargo';
71
71
  const registryUrl = `http://localhost:5000${cargoBasePath}`; // TODO: Make configurable
72
72
  const cargoRegistry = new CargoRegistry(this.storage, this.authManager, cargoBasePath, registryUrl);
73
73
  await cargoRegistry.init();
@@ -76,7 +76,7 @@ export class SmartRegistry {
76
76
 
77
77
  // Initialize Composer registry if enabled
78
78
  if (this.config.composer?.enabled) {
79
- const composerBasePath = this.config.composer.basePath || '/composer';
79
+ const composerBasePath = this.config.composer.basePath ?? '/composer';
80
80
  const registryUrl = `http://localhost:5000${composerBasePath}`; // TODO: Make configurable
81
81
  const composerRegistry = new ComposerRegistry(this.storage, this.authManager, composerBasePath, registryUrl);
82
82
  await composerRegistry.init();
@@ -85,7 +85,7 @@ export class SmartRegistry {
85
85
 
86
86
  // Initialize PyPI registry if enabled
87
87
  if (this.config.pypi?.enabled) {
88
- const pypiBasePath = this.config.pypi.basePath || '/pypi';
88
+ const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
89
89
  const registryUrl = `http://localhost:5000`; // TODO: Make configurable
90
90
  const pypiRegistry = new PypiRegistry(this.storage, this.authManager, pypiBasePath, registryUrl);
91
91
  await pypiRegistry.init();
@@ -94,7 +94,7 @@ export class SmartRegistry {
94
94
 
95
95
  // Initialize RubyGems registry if enabled
96
96
  if (this.config.rubygems?.enabled) {
97
- const rubygemsBasePath = this.config.rubygems.basePath || '/rubygems';
97
+ const rubygemsBasePath = this.config.rubygems.basePath ?? '/rubygems';
98
98
  const registryUrl = `http://localhost:5000${rubygemsBasePath}`; // TODO: Make configurable
99
99
  const rubygemsRegistry = new RubyGemsRegistry(this.storage, this.authManager, rubygemsBasePath, registryUrl);
100
100
  await rubygemsRegistry.init();
@@ -153,7 +153,7 @@ export class SmartRegistry {
153
153
 
154
154
  // Route to PyPI registry (also handles /simple prefix)
155
155
  if (this.config.pypi?.enabled) {
156
- const pypiBasePath = this.config.pypi.basePath || '/pypi';
156
+ const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
157
157
  if (path.startsWith(pypiBasePath) || path.startsWith('/simple')) {
158
158
  const pypiRegistry = this.registries.get('pypi');
159
159
  if (pypiRegistry) {
@@ -1,4 +1,5 @@
1
1
  import type { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js';
2
+ import * as crypto from 'crypto';
2
3
 
3
4
  /**
4
5
  * Unified authentication manager for all registry protocols
@@ -136,7 +137,7 @@ export class AuthManager {
136
137
  * @param userId - User ID
137
138
  * @param scopes - Permission scopes
138
139
  * @param expiresIn - Expiration time in seconds
139
- * @returns JWT token string
140
+ * @returns JWT token string (HMAC-SHA256 signed)
140
141
  */
141
142
  public async createOciToken(
142
143
  userId: string,
@@ -158,9 +159,17 @@ export class AuthManager {
158
159
  access: this.scopesToOciAccess(scopes),
159
160
  };
160
161
 
161
- // In production, use proper JWT library with signing
162
- // For now, return JSON string (mock JWT)
163
- return JSON.stringify(payload);
162
+ // Create JWT with HMAC-SHA256 signature
163
+ const header = { alg: 'HS256', typ: 'JWT' };
164
+ const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
165
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
166
+
167
+ const signature = crypto
168
+ .createHmac('sha256', this.config.jwtSecret)
169
+ .update(`${headerB64}.${payloadB64}`)
170
+ .digest('base64url');
171
+
172
+ return `${headerB64}.${payloadB64}.${signature}`;
164
173
  }
165
174
 
166
175
  /**
@@ -170,8 +179,25 @@ export class AuthManager {
170
179
  */
171
180
  public async validateOciToken(jwt: string): Promise<IAuthToken | null> {
172
181
  try {
173
- // In production, verify JWT signature
174
- const payload = JSON.parse(jwt);
182
+ const parts = jwt.split('.');
183
+ if (parts.length !== 3) {
184
+ return null;
185
+ }
186
+
187
+ const [headerB64, payloadB64, signatureB64] = parts;
188
+
189
+ // Verify signature
190
+ const expectedSignature = crypto
191
+ .createHmac('sha256', this.config.jwtSecret)
192
+ .update(`${headerB64}.${payloadB64}`)
193
+ .digest('base64url');
194
+
195
+ if (signatureB64 !== expectedSignature) {
196
+ return null;
197
+ }
198
+
199
+ // Decode and parse payload
200
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'));
175
201
 
176
202
  // Check expiration
177
203
  const now = Math.floor(Date.now() / 1000);
@@ -179,6 +205,11 @@ export class AuthManager {
179
205
  return null;
180
206
  }
181
207
 
208
+ // Check not-before time
209
+ if (payload.nbf && payload.nbf > now) {
210
+ return null;
211
+ }
212
+
182
213
  // Convert to unified token format
183
214
  const scopes = this.ociAccessToScopes(payload.access || []);
184
215
 
@@ -180,11 +180,7 @@ export class OciRegistry extends BaseRegistry {
180
180
  body?: Buffer | any
181
181
  ): Promise<IResponse> {
182
182
  if (!await this.checkPermission(token, repository, 'push')) {
183
- return {
184
- status: 401,
185
- headers: {},
186
- body: this.createError('DENIED', 'Insufficient permissions'),
187
- };
183
+ return this.createUnauthorizedResponse(repository, 'push');
188
184
  }
189
185
 
190
186
  // Check for monolithic upload (digest + body provided)
@@ -255,11 +251,7 @@ export class OciRegistry extends BaseRegistry {
255
251
  }
256
252
 
257
253
  if (!await this.checkPermission(token, session.repository, 'push')) {
258
- return {
259
- status: 401,
260
- headers: {},
261
- body: this.createError('DENIED', 'Insufficient permissions'),
262
- };
254
+ return this.createUnauthorizedResponse(session.repository, 'push');
263
255
  }
264
256
 
265
257
  switch (method) {
@@ -336,11 +328,7 @@ export class OciRegistry extends BaseRegistry {
336
328
  token: IAuthToken | null
337
329
  ): Promise<IResponse> {
338
330
  if (!await this.checkPermission(token, repository, 'pull')) {
339
- return {
340
- status: 401,
341
- headers: {},
342
- body: null,
343
- };
331
+ return this.createUnauthorizedHeadResponse(repository, 'pull');
344
332
  }
345
333
 
346
334
  // Similar logic as getManifest but return headers only
@@ -437,11 +425,7 @@ export class OciRegistry extends BaseRegistry {
437
425
  }
438
426
 
439
427
  if (!await this.checkPermission(token, repository, 'delete')) {
440
- return {
441
- status: 401,
442
- headers: {},
443
- body: this.createError('DENIED', 'Insufficient permissions'),
444
- };
428
+ return this.createUnauthorizedResponse(repository, 'delete');
445
429
  }
446
430
 
447
431
  await this.storage.deleteOciManifest(repository, digest);
@@ -460,11 +444,7 @@ export class OciRegistry extends BaseRegistry {
460
444
  range?: string
461
445
  ): Promise<IResponse> {
462
446
  if (!await this.checkPermission(token, repository, 'pull')) {
463
- return {
464
- status: 401,
465
- headers: {},
466
- body: this.createError('DENIED', 'Insufficient permissions'),
467
- };
447
+ return this.createUnauthorizedResponse(repository, 'pull');
468
448
  }
469
449
 
470
450
  const data = await this.storage.getOciBlob(digest);
@@ -492,7 +472,7 @@ export class OciRegistry extends BaseRegistry {
492
472
  token: IAuthToken | null
493
473
  ): Promise<IResponse> {
494
474
  if (!await this.checkPermission(token, repository, 'pull')) {
495
- return { status: 401, headers: {}, body: null };
475
+ return this.createUnauthorizedHeadResponse(repository, 'pull');
496
476
  }
497
477
 
498
478
  const exists = await this.storage.ociBlobExists(digest);
@@ -518,11 +498,7 @@ export class OciRegistry extends BaseRegistry {
518
498
  token: IAuthToken | null
519
499
  ): Promise<IResponse> {
520
500
  if (!await this.checkPermission(token, repository, 'delete')) {
521
- return {
522
- status: 401,
523
- headers: {},
524
- body: this.createError('DENIED', 'Insufficient permissions'),
525
- };
501
+ return this.createUnauthorizedResponse(repository, 'delete');
526
502
  }
527
503
 
528
504
  await this.storage.deleteOciBlob(digest);
@@ -631,11 +607,7 @@ export class OciRegistry extends BaseRegistry {
631
607
  query: Record<string, string>
632
608
  ): Promise<IResponse> {
633
609
  if (!await this.checkPermission(token, repository, 'pull')) {
634
- return {
635
- status: 401,
636
- headers: {},
637
- body: this.createError('DENIED', 'Insufficient permissions'),
638
- };
610
+ return this.createUnauthorizedResponse(repository, 'pull');
639
611
  }
640
612
 
641
613
  const tags = await this.getTagsData(repository);
@@ -660,11 +632,7 @@ export class OciRegistry extends BaseRegistry {
660
632
  query: Record<string, string>
661
633
  ): Promise<IResponse> {
662
634
  if (!await this.checkPermission(token, repository, 'pull')) {
663
- return {
664
- status: 401,
665
- headers: {},
666
- body: this.createError('DENIED', 'Insufficient permissions'),
667
- };
635
+ return this.createUnauthorizedResponse(repository, 'pull');
668
636
  }
669
637
 
670
638
  const response: IReferrersResponse = {
@@ -712,6 +680,33 @@ export class OciRegistry extends BaseRegistry {
712
680
  };
713
681
  }
714
682
 
683
+ /**
684
+ * Create an unauthorized response with proper WWW-Authenticate header.
685
+ * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
686
+ */
687
+ private createUnauthorizedResponse(repository: string, action: string): IResponse {
688
+ return {
689
+ status: 401,
690
+ headers: {
691
+ 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`,
692
+ },
693
+ body: this.createError('DENIED', 'Insufficient permissions'),
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Create an unauthorized HEAD response (no body per HTTP spec).
699
+ */
700
+ private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
701
+ return {
702
+ status: 401,
703
+ headers: {
704
+ 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`,
705
+ },
706
+ body: null,
707
+ };
708
+ }
709
+
715
710
  private startUploadSessionCleanup(): void {
716
711
  this.cleanupInterval = setInterval(() => {
717
712
  const now = new Date();
package/ts/plugins.ts CHANGED
@@ -4,11 +4,12 @@ import * as path from 'path';
4
4
  export { path };
5
5
 
6
6
  // @push.rocks scope
7
+ import * as smartarchive from '@push.rocks/smartarchive';
7
8
  import * as smartbucket from '@push.rocks/smartbucket';
8
9
  import * as smartlog from '@push.rocks/smartlog';
9
10
  import * as smartpath from '@push.rocks/smartpath';
10
11
 
11
- export { smartbucket, smartlog, smartpath };
12
+ export { smartarchive, smartbucket, smartlog, smartpath };
12
13
 
13
14
  // @tsclass scope
14
15
  import * as tsclass from '@tsclass/tsclass';
@@ -85,14 +85,14 @@ export class PypiRegistry extends BaseRegistry {
85
85
  return this.handleUpload(context, token);
86
86
  }
87
87
 
88
- // Package metadata JSON API: GET /pypi/{package}/json
89
- const jsonMatch = path.match(/^\/pypi\/([^\/]+)\/json$/);
88
+ // Package metadata JSON API: GET /{package}/json
89
+ const jsonMatch = path.match(/^\/([^\/]+)\/json$/);
90
90
  if (jsonMatch && context.method === 'GET') {
91
91
  return this.handlePackageJson(jsonMatch[1]);
92
92
  }
93
93
 
94
- // Version-specific JSON API: GET /pypi/{package}/{version}/json
95
- const versionJsonMatch = path.match(/^\/pypi\/([^\/]+)\/([^\/]+)\/json$/);
94
+ // Version-specific JSON API: GET /{package}/{version}/json
95
+ const versionJsonMatch = path.match(/^\/([^\/]+)\/([^\/]+)\/json$/);
96
96
  if (versionJsonMatch && context.method === 'GET') {
97
97
  return this.handleVersionJson(versionJsonMatch[1], versionJsonMatch[2]);
98
98
  }
@@ -118,7 +118,7 @@ export class PypiRegistry extends BaseRegistry {
118
118
  return {
119
119
  status: 404,
120
120
  headers: { 'Content-Type': 'application/json' },
121
- body: Buffer.from(JSON.stringify({ message: 'Not Found' })),
121
+ body: { error: 'Not Found' },
122
122
  };
123
123
  }
124
124
 
@@ -185,7 +185,7 @@ export class PypiRegistry extends BaseRegistry {
185
185
  'Content-Type': 'application/vnd.pypi.simple.v1+json',
186
186
  'Cache-Control': 'public, max-age=600'
187
187
  },
188
- body: Buffer.from(JSON.stringify(response)),
188
+ body: response,
189
189
  };
190
190
  } else {
191
191
  // PEP 503: HTML response
@@ -200,7 +200,7 @@ export class PypiRegistry extends BaseRegistry {
200
200
  'Content-Type': 'text/html; charset=utf-8',
201
201
  'Cache-Control': 'public, max-age=600'
202
202
  },
203
- body: Buffer.from(html),
203
+ body: html,
204
204
  };
205
205
  }
206
206
  }
@@ -215,11 +215,7 @@ export class PypiRegistry extends BaseRegistry {
215
215
  // Get package metadata
216
216
  const metadata = await this.storage.getPypiPackageMetadata(normalized);
217
217
  if (!metadata) {
218
- return {
219
- status: 404,
220
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
221
- body: Buffer.from('<html><body><h1>404 Not Found</h1></body></html>'),
222
- };
218
+ return this.errorResponse(404, 'Package not found');
223
219
  }
224
220
 
225
221
  // Build file list from all versions
@@ -251,7 +247,7 @@ export class PypiRegistry extends BaseRegistry {
251
247
  'Content-Type': 'application/vnd.pypi.simple.v1+json',
252
248
  'Cache-Control': 'public, max-age=300'
253
249
  },
254
- body: Buffer.from(JSON.stringify(response)),
250
+ body: response,
255
251
  };
256
252
  } else {
257
253
  // PEP 503: HTML response
@@ -266,7 +262,7 @@ export class PypiRegistry extends BaseRegistry {
266
262
  'Content-Type': 'text/html; charset=utf-8',
267
263
  'Cache-Control': 'public, max-age=300'
268
264
  },
269
- body: Buffer.from(html),
265
+ body: html,
270
266
  };
271
267
  }
272
268
  }
@@ -315,7 +311,7 @@ export class PypiRegistry extends BaseRegistry {
315
311
  'Content-Type': 'application/json',
316
312
  'WWW-Authenticate': 'Basic realm="PyPI"'
317
313
  },
318
- body: Buffer.from(JSON.stringify({ message: 'Authentication required' })),
314
+ body: { error: 'Authentication required' },
319
315
  };
320
316
  }
321
317
 
@@ -327,11 +323,13 @@ export class PypiRegistry extends BaseRegistry {
327
323
  return this.errorResponse(400, 'Invalid upload request');
328
324
  }
329
325
 
330
- // Extract required fields
326
+ // Extract required fields - support both nested and flat body formats
331
327
  const packageName = formData.name;
332
328
  const version = formData.version;
333
- const filename = formData.content?.filename;
334
- const fileData = formData.content?.data as Buffer;
329
+ // Support both: formData.content.filename (multipart parsed) and formData.filename (flat)
330
+ const filename = formData.content?.filename || formData.filename;
331
+ // Support both: formData.content.data (multipart parsed) and formData.content (Buffer directly)
332
+ const fileData = (formData.content?.data || (Buffer.isBuffer(formData.content) ? formData.content : null)) as Buffer;
335
333
  const filetype = formData.filetype; // 'bdist_wheel' or 'sdist'
336
334
  const pyversion = formData.pyversion;
337
335
 
@@ -431,12 +429,12 @@ export class PypiRegistry extends BaseRegistry {
431
429
  });
432
430
 
433
431
  return {
434
- status: 200,
432
+ status: 201,
435
433
  headers: { 'Content-Type': 'application/json' },
436
- body: Buffer.from(JSON.stringify({
434
+ body: {
437
435
  message: 'Package uploaded successfully',
438
436
  url: `${this.registryUrl}/pypi/packages/${normalized}/${filename}`
439
- })),
437
+ },
440
438
  };
441
439
  } catch (error) {
442
440
  this.logger.log('error', 'Upload failed', { error: (error as Error).message });
@@ -455,7 +453,7 @@ export class PypiRegistry extends BaseRegistry {
455
453
  return {
456
454
  status: 404,
457
455
  headers: { 'Content-Type': 'application/json' },
458
- body: Buffer.from(JSON.stringify({ message: 'File not found' })),
456
+ body: { error: 'File not found' },
459
457
  };
460
458
  }
461
459
 
@@ -472,6 +470,7 @@ export class PypiRegistry extends BaseRegistry {
472
470
 
473
471
  /**
474
472
  * Handle package JSON API (all versions)
473
+ * Returns format compatible with official PyPI JSON API
475
474
  */
476
475
  private async handlePackageJson(packageName: string): Promise<IResponse> {
477
476
  const normalized = helpers.normalizePypiPackageName(packageName);
@@ -481,18 +480,67 @@ export class PypiRegistry extends BaseRegistry {
481
480
  return this.errorResponse(404, 'Package not found');
482
481
  }
483
482
 
483
+ // Find latest version for info
484
+ const versions = Object.keys(metadata.versions || {});
485
+ const latestVersion = versions.length > 0 ? versions[versions.length - 1] : null;
486
+ const latestMeta = latestVersion ? metadata.versions[latestVersion] : null;
487
+
488
+ // Build URLs array from latest version files
489
+ const urls = latestMeta?.files?.map((file: any) => ({
490
+ filename: file.filename,
491
+ url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
492
+ digests: file.hashes,
493
+ requires_python: file['requires-python'],
494
+ size: file.size,
495
+ upload_time: file['upload-time'],
496
+ packagetype: file.filetype,
497
+ python_version: file.python_version,
498
+ })) || [];
499
+
500
+ // Build releases object
501
+ const releases: Record<string, any[]> = {};
502
+ for (const [ver, verMeta] of Object.entries(metadata.versions || {})) {
503
+ releases[ver] = (verMeta as any).files?.map((file: any) => ({
504
+ filename: file.filename,
505
+ url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
506
+ digests: file.hashes,
507
+ requires_python: file['requires-python'],
508
+ size: file.size,
509
+ upload_time: file['upload-time'],
510
+ packagetype: file.filetype,
511
+ python_version: file.python_version,
512
+ })) || [];
513
+ }
514
+
515
+ const response = {
516
+ info: {
517
+ name: normalized,
518
+ version: latestVersion,
519
+ summary: latestMeta?.metadata?.summary,
520
+ description: latestMeta?.metadata?.description,
521
+ author: latestMeta?.metadata?.author,
522
+ author_email: latestMeta?.metadata?.['author-email'],
523
+ license: latestMeta?.metadata?.license,
524
+ requires_python: latestMeta?.files?.[0]?.['requires-python'],
525
+ ...latestMeta?.metadata,
526
+ },
527
+ urls,
528
+ releases,
529
+ };
530
+
484
531
  return {
485
532
  status: 200,
486
533
  headers: {
487
534
  'Content-Type': 'application/json',
488
535
  'Cache-Control': 'public, max-age=300'
489
536
  },
490
- body: Buffer.from(JSON.stringify(metadata)),
537
+ body: response,
491
538
  };
492
539
  }
493
540
 
494
541
  /**
495
542
  * Handle version-specific JSON API
543
+ * Returns format compatible with official PyPI JSON API
496
544
  */
497
545
  private async handleVersionJson(packageName: string, version: string): Promise<IResponse> {
498
546
  const normalized = helpers.normalizePypiPackageName(packageName);
@@ -502,13 +550,42 @@ export class PypiRegistry extends BaseRegistry {
502
550
  return this.errorResponse(404, 'Version not found');
503
551
  }
504
552
 
553
+ const verMeta = metadata.versions[version];
554
+
555
+ // Build URLs array from version files
556
+ const urls = verMeta.files?.map((file: any) => ({
557
+ filename: file.filename,
558
+ url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
559
+ digests: file.hashes,
560
+ requires_python: file['requires-python'],
561
+ size: file.size,
562
+ upload_time: file['upload-time'],
563
+ packagetype: file.filetype,
564
+ python_version: file.python_version,
565
+ })) || [];
566
+
567
+ const response = {
568
+ info: {
569
+ name: normalized,
570
+ version,
571
+ summary: verMeta.metadata?.summary,
572
+ description: verMeta.metadata?.description,
573
+ author: verMeta.metadata?.author,
574
+ author_email: verMeta.metadata?.['author-email'],
575
+ license: verMeta.metadata?.license,
576
+ requires_python: verMeta.files?.[0]?.['requires-python'],
577
+ ...verMeta.metadata,
578
+ },
579
+ urls,
580
+ };
581
+
505
582
  return {
506
583
  status: 200,
507
584
  headers: {
508
585
  'Content-Type': 'application/json',
509
586
  'Cache-Control': 'public, max-age=300'
510
587
  },
511
- body: Buffer.from(JSON.stringify(metadata.versions[version])),
588
+ body: response,
512
589
  };
513
590
  }
514
591
 
@@ -570,11 +647,11 @@ export class PypiRegistry extends BaseRegistry {
570
647
  * Helper: Create error response
571
648
  */
572
649
  private errorResponse(status: number, message: string): IResponse {
573
- const error: IPypiError = { message, status };
650
+ const error: IPypiError = { error: message, status };
574
651
  return {
575
652
  status,
576
653
  headers: { 'Content-Type': 'application/json' },
577
- body: Buffer.from(JSON.stringify(error)),
654
+ body: error,
578
655
  };
579
656
  }
580
657
  }
@@ -244,7 +244,7 @@ export interface IPypiUploadResponse {
244
244
  */
245
245
  export interface IPypiError {
246
246
  /** Error message */
247
- message: string;
247
+ error: string;
248
248
  /** HTTP status code */
249
249
  status?: number;
250
250
  /** Additional error details */