@push.rocks/smartregistry 1.8.0 → 2.1.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.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartregistry',
6
- version: '1.8.0',
6
+ version: '2.1.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,15 +41,19 @@ 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';
45
- const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath);
44
+ const ociBasePath = this.config.oci.basePath ?? '/oci';
45
+ const ociTokens = this.config.auth.ociTokens?.enabled ? {
46
+ realm: this.config.auth.ociTokens.realm,
47
+ service: this.config.auth.ociTokens.service,
48
+ } : undefined;
49
+ const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath, ociTokens);
46
50
  await ociRegistry.init();
47
51
  this.registries.set('oci', ociRegistry);
48
52
  }
49
53
 
50
54
  // Initialize NPM registry if enabled
51
55
  if (this.config.npm?.enabled) {
52
- const npmBasePath = this.config.npm.basePath || '/npm';
56
+ const npmBasePath = this.config.npm.basePath ?? '/npm';
53
57
  const registryUrl = `http://localhost:5000${npmBasePath}`; // TODO: Make configurable
54
58
  const npmRegistry = new NpmRegistry(this.storage, this.authManager, npmBasePath, registryUrl);
55
59
  await npmRegistry.init();
@@ -58,7 +62,7 @@ export class SmartRegistry {
58
62
 
59
63
  // Initialize Maven registry if enabled
60
64
  if (this.config.maven?.enabled) {
61
- const mavenBasePath = this.config.maven.basePath || '/maven';
65
+ const mavenBasePath = this.config.maven.basePath ?? '/maven';
62
66
  const registryUrl = `http://localhost:5000${mavenBasePath}`; // TODO: Make configurable
63
67
  const mavenRegistry = new MavenRegistry(this.storage, this.authManager, mavenBasePath, registryUrl);
64
68
  await mavenRegistry.init();
@@ -67,7 +71,7 @@ export class SmartRegistry {
67
71
 
68
72
  // Initialize Cargo registry if enabled
69
73
  if (this.config.cargo?.enabled) {
70
- const cargoBasePath = this.config.cargo.basePath || '/cargo';
74
+ const cargoBasePath = this.config.cargo.basePath ?? '/cargo';
71
75
  const registryUrl = `http://localhost:5000${cargoBasePath}`; // TODO: Make configurable
72
76
  const cargoRegistry = new CargoRegistry(this.storage, this.authManager, cargoBasePath, registryUrl);
73
77
  await cargoRegistry.init();
@@ -76,7 +80,7 @@ export class SmartRegistry {
76
80
 
77
81
  // Initialize Composer registry if enabled
78
82
  if (this.config.composer?.enabled) {
79
- const composerBasePath = this.config.composer.basePath || '/composer';
83
+ const composerBasePath = this.config.composer.basePath ?? '/composer';
80
84
  const registryUrl = `http://localhost:5000${composerBasePath}`; // TODO: Make configurable
81
85
  const composerRegistry = new ComposerRegistry(this.storage, this.authManager, composerBasePath, registryUrl);
82
86
  await composerRegistry.init();
@@ -85,7 +89,7 @@ export class SmartRegistry {
85
89
 
86
90
  // Initialize PyPI registry if enabled
87
91
  if (this.config.pypi?.enabled) {
88
- const pypiBasePath = this.config.pypi.basePath || '/pypi';
92
+ const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
89
93
  const registryUrl = `http://localhost:5000`; // TODO: Make configurable
90
94
  const pypiRegistry = new PypiRegistry(this.storage, this.authManager, pypiBasePath, registryUrl);
91
95
  await pypiRegistry.init();
@@ -94,7 +98,7 @@ export class SmartRegistry {
94
98
 
95
99
  // Initialize RubyGems registry if enabled
96
100
  if (this.config.rubygems?.enabled) {
97
- const rubygemsBasePath = this.config.rubygems.basePath || '/rubygems';
101
+ const rubygemsBasePath = this.config.rubygems.basePath ?? '/rubygems';
98
102
  const registryUrl = `http://localhost:5000${rubygemsBasePath}`; // TODO: Make configurable
99
103
  const rubygemsRegistry = new RubyGemsRegistry(this.storage, this.authManager, rubygemsBasePath, registryUrl);
100
104
  await rubygemsRegistry.init();
@@ -153,7 +157,7 @@ export class SmartRegistry {
153
157
 
154
158
  // Route to PyPI registry (also handles /simple prefix)
155
159
  if (this.config.pypi?.enabled) {
156
- const pypiBasePath = this.config.pypi.basePath || '/pypi';
160
+ const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
157
161
  if (path.startsWith(pypiBasePath) || path.startsWith('/simple')) {
158
162
  const pypiRegistry = this.registries.get('pypi');
159
163
  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
 
@@ -20,12 +20,19 @@ export class OciRegistry extends BaseRegistry {
20
20
  private uploadSessions: Map<string, IUploadSession> = new Map();
21
21
  private basePath: string = '/oci';
22
22
  private cleanupInterval?: NodeJS.Timeout;
23
-
24
- constructor(storage: RegistryStorage, authManager: AuthManager, basePath: string = '/oci') {
23
+ private ociTokens?: { realm: string; service: string };
24
+
25
+ constructor(
26
+ storage: RegistryStorage,
27
+ authManager: AuthManager,
28
+ basePath: string = '/oci',
29
+ ociTokens?: { realm: string; service: string }
30
+ ) {
25
31
  super();
26
32
  this.storage = storage;
27
33
  this.authManager = authManager;
28
34
  this.basePath = basePath;
35
+ this.ociTokens = ociTokens;
29
36
  }
30
37
 
31
38
  public async init(): Promise<void> {
@@ -180,11 +187,7 @@ export class OciRegistry extends BaseRegistry {
180
187
  body?: Buffer | any
181
188
  ): Promise<IResponse> {
182
189
  if (!await this.checkPermission(token, repository, 'push')) {
183
- return {
184
- status: 401,
185
- headers: {},
186
- body: this.createError('DENIED', 'Insufficient permissions'),
187
- };
190
+ return this.createUnauthorizedResponse(repository, 'push');
188
191
  }
189
192
 
190
193
  // Check for monolithic upload (digest + body provided)
@@ -255,11 +258,7 @@ export class OciRegistry extends BaseRegistry {
255
258
  }
256
259
 
257
260
  if (!await this.checkPermission(token, session.repository, 'push')) {
258
- return {
259
- status: 401,
260
- headers: {},
261
- body: this.createError('DENIED', 'Insufficient permissions'),
262
- };
261
+ return this.createUnauthorizedResponse(session.repository, 'push');
263
262
  }
264
263
 
265
264
  switch (method) {
@@ -288,13 +287,7 @@ export class OciRegistry extends BaseRegistry {
288
287
  headers?: Record<string, string>
289
288
  ): Promise<IResponse> {
290
289
  if (!await this.checkPermission(token, repository, 'pull')) {
291
- return {
292
- status: 401,
293
- headers: {
294
- 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:pull"`,
295
- },
296
- body: this.createError('DENIED', 'Insufficient permissions'),
297
- };
290
+ return this.createUnauthorizedResponse(repository, 'pull');
298
291
  }
299
292
 
300
293
  // Resolve tag to digest if needed
@@ -336,11 +329,7 @@ export class OciRegistry extends BaseRegistry {
336
329
  token: IAuthToken | null
337
330
  ): Promise<IResponse> {
338
331
  if (!await this.checkPermission(token, repository, 'pull')) {
339
- return {
340
- status: 401,
341
- headers: {},
342
- body: null,
343
- };
332
+ return this.createUnauthorizedHeadResponse(repository, 'pull');
344
333
  }
345
334
 
346
335
  // Similar logic as getManifest but return headers only
@@ -379,13 +368,7 @@ export class OciRegistry extends BaseRegistry {
379
368
  headers?: Record<string, string>
380
369
  ): Promise<IResponse> {
381
370
  if (!await this.checkPermission(token, repository, 'push')) {
382
- return {
383
- status: 401,
384
- headers: {
385
- 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:push"`,
386
- },
387
- body: this.createError('DENIED', 'Insufficient permissions'),
388
- };
371
+ return this.createUnauthorizedResponse(repository, 'push');
389
372
  }
390
373
 
391
374
  if (!body) {
@@ -437,11 +420,7 @@ export class OciRegistry extends BaseRegistry {
437
420
  }
438
421
 
439
422
  if (!await this.checkPermission(token, repository, 'delete')) {
440
- return {
441
- status: 401,
442
- headers: {},
443
- body: this.createError('DENIED', 'Insufficient permissions'),
444
- };
423
+ return this.createUnauthorizedResponse(repository, 'delete');
445
424
  }
446
425
 
447
426
  await this.storage.deleteOciManifest(repository, digest);
@@ -460,11 +439,7 @@ export class OciRegistry extends BaseRegistry {
460
439
  range?: string
461
440
  ): Promise<IResponse> {
462
441
  if (!await this.checkPermission(token, repository, 'pull')) {
463
- return {
464
- status: 401,
465
- headers: {},
466
- body: this.createError('DENIED', 'Insufficient permissions'),
467
- };
442
+ return this.createUnauthorizedResponse(repository, 'pull');
468
443
  }
469
444
 
470
445
  const data = await this.storage.getOciBlob(digest);
@@ -492,7 +467,7 @@ export class OciRegistry extends BaseRegistry {
492
467
  token: IAuthToken | null
493
468
  ): Promise<IResponse> {
494
469
  if (!await this.checkPermission(token, repository, 'pull')) {
495
- return { status: 401, headers: {}, body: null };
470
+ return this.createUnauthorizedHeadResponse(repository, 'pull');
496
471
  }
497
472
 
498
473
  const exists = await this.storage.ociBlobExists(digest);
@@ -518,11 +493,7 @@ export class OciRegistry extends BaseRegistry {
518
493
  token: IAuthToken | null
519
494
  ): Promise<IResponse> {
520
495
  if (!await this.checkPermission(token, repository, 'delete')) {
521
- return {
522
- status: 401,
523
- headers: {},
524
- body: this.createError('DENIED', 'Insufficient permissions'),
525
- };
496
+ return this.createUnauthorizedResponse(repository, 'delete');
526
497
  }
527
498
 
528
499
  await this.storage.deleteOciBlob(digest);
@@ -631,11 +602,7 @@ export class OciRegistry extends BaseRegistry {
631
602
  query: Record<string, string>
632
603
  ): Promise<IResponse> {
633
604
  if (!await this.checkPermission(token, repository, 'pull')) {
634
- return {
635
- status: 401,
636
- headers: {},
637
- body: this.createError('DENIED', 'Insufficient permissions'),
638
- };
605
+ return this.createUnauthorizedResponse(repository, 'pull');
639
606
  }
640
607
 
641
608
  const tags = await this.getTagsData(repository);
@@ -660,11 +627,7 @@ export class OciRegistry extends BaseRegistry {
660
627
  query: Record<string, string>
661
628
  ): Promise<IResponse> {
662
629
  if (!await this.checkPermission(token, repository, 'pull')) {
663
- return {
664
- status: 401,
665
- headers: {},
666
- body: this.createError('DENIED', 'Insufficient permissions'),
667
- };
630
+ return this.createUnauthorizedResponse(repository, 'pull');
668
631
  }
669
632
 
670
633
  const response: IReferrersResponse = {
@@ -712,6 +675,37 @@ export class OciRegistry extends BaseRegistry {
712
675
  };
713
676
  }
714
677
 
678
+ /**
679
+ * Create an unauthorized response with proper WWW-Authenticate header.
680
+ * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
681
+ */
682
+ private createUnauthorizedResponse(repository: string, action: string): IResponse {
683
+ const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
684
+ const service = this.ociTokens?.service || 'registry';
685
+ return {
686
+ status: 401,
687
+ headers: {
688
+ 'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`,
689
+ },
690
+ body: this.createError('DENIED', 'Insufficient permissions'),
691
+ };
692
+ }
693
+
694
+ /**
695
+ * Create an unauthorized HEAD response (no body per HTTP spec).
696
+ */
697
+ private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
698
+ const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
699
+ const service = this.ociTokens?.service || 'registry';
700
+ return {
701
+ status: 401,
702
+ headers: {
703
+ 'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`,
704
+ },
705
+ body: null,
706
+ };
707
+ }
708
+
715
709
  private startUploadSessionCleanup(): void {
716
710
  this.cleanupInterval = setInterval(() => {
717
711
  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 */