@push.rocks/smartregistry 1.5.0 → 1.6.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.
@@ -0,0 +1,598 @@
1
+ import { Smartlog } from '@push.rocks/smartlog';
2
+ import { BaseRegistry } from '../core/classes.baseregistry.js';
3
+ import { RegistryStorage } from '../core/classes.registrystorage.js';
4
+ import { AuthManager } from '../core/classes.authmanager.js';
5
+ import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
6
+ import type {
7
+ IRubyGemsMetadata,
8
+ IRubyGemsVersionMetadata,
9
+ IRubyGemsUploadResponse,
10
+ IRubyGemsYankResponse,
11
+ IRubyGemsError,
12
+ ICompactIndexInfoEntry,
13
+ } from './interfaces.rubygems.js';
14
+ import * as helpers from './helpers.rubygems.js';
15
+
16
+ /**
17
+ * RubyGems registry implementation
18
+ * Implements Compact Index API and RubyGems protocol
19
+ */
20
+ export class RubyGemsRegistry extends BaseRegistry {
21
+ private storage: RegistryStorage;
22
+ private authManager: AuthManager;
23
+ private basePath: string = '/rubygems';
24
+ private registryUrl: string;
25
+ private logger: Smartlog;
26
+
27
+ constructor(
28
+ storage: RegistryStorage,
29
+ authManager: AuthManager,
30
+ basePath: string = '/rubygems',
31
+ registryUrl: string = 'http://localhost:5000/rubygems'
32
+ ) {
33
+ super();
34
+ this.storage = storage;
35
+ this.authManager = authManager;
36
+ this.basePath = basePath;
37
+ this.registryUrl = registryUrl;
38
+
39
+ // Initialize logger
40
+ this.logger = new Smartlog({
41
+ logContext: {
42
+ company: 'push.rocks',
43
+ companyunit: 'smartregistry',
44
+ containerName: 'rubygems-registry',
45
+ environment: (process.env.NODE_ENV as any) || 'development',
46
+ runtime: 'node',
47
+ zone: 'rubygems'
48
+ }
49
+ });
50
+ this.logger.enableConsole();
51
+ }
52
+
53
+ public async init(): Promise<void> {
54
+ // Initialize Compact Index files if not exist
55
+ const existingVersions = await this.storage.getRubyGemsVersions();
56
+ if (!existingVersions) {
57
+ const versions = helpers.generateCompactIndexVersions([]);
58
+ await this.storage.putRubyGemsVersions(versions);
59
+ this.logger.log('info', 'Initialized RubyGems Compact Index');
60
+ }
61
+
62
+ const existingNames = await this.storage.getRubyGemsNames();
63
+ if (!existingNames) {
64
+ const names = helpers.generateNamesFile([]);
65
+ await this.storage.putRubyGemsNames(names);
66
+ this.logger.log('info', 'Initialized RubyGems names file');
67
+ }
68
+ }
69
+
70
+ public getBasePath(): string {
71
+ return this.basePath;
72
+ }
73
+
74
+ public async handleRequest(context: IRequestContext): Promise<IResponse> {
75
+ let path = context.path.replace(this.basePath, '');
76
+
77
+ // Extract token (Authorization header)
78
+ const token = await this.extractToken(context);
79
+
80
+ this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
81
+ method: context.method,
82
+ path,
83
+ hasAuth: !!token
84
+ });
85
+
86
+ // Compact Index endpoints
87
+ if (path === '/versions' && context.method === 'GET') {
88
+ return this.handleVersionsFile();
89
+ }
90
+
91
+ if (path === '/names' && context.method === 'GET') {
92
+ return this.handleNamesFile();
93
+ }
94
+
95
+ // Info file: GET /info/{gem}
96
+ const infoMatch = path.match(/^\/info\/([^\/]+)$/);
97
+ if (infoMatch && context.method === 'GET') {
98
+ return this.handleInfoFile(infoMatch[1]);
99
+ }
100
+
101
+ // Gem download: GET /gems/{gem}-{version}[-{platform}].gem
102
+ const downloadMatch = path.match(/^\/gems\/(.+\.gem)$/);
103
+ if (downloadMatch && context.method === 'GET') {
104
+ return this.handleDownload(downloadMatch[1]);
105
+ }
106
+
107
+ // API v1 endpoints
108
+ if (path.startsWith('/api/v1/')) {
109
+ return this.handleApiRequest(path.substring(8), context, token);
110
+ }
111
+
112
+ return {
113
+ status: 404,
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: Buffer.from(JSON.stringify({ message: 'Not Found' })),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Check if token has permission for resource
121
+ */
122
+ protected async checkPermission(
123
+ token: IAuthToken | null,
124
+ resource: string,
125
+ action: string
126
+ ): Promise<boolean> {
127
+ if (!token) return false;
128
+ return this.authManager.authorize(token, `rubygems:gem:${resource}`, action);
129
+ }
130
+
131
+ /**
132
+ * Extract authentication token from request
133
+ */
134
+ private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
135
+ const authHeader = context.headers['authorization'] || context.headers['Authorization'];
136
+ if (!authHeader) return null;
137
+
138
+ // RubyGems typically uses plain API key in Authorization header
139
+ return this.authManager.validateToken(authHeader, 'rubygems');
140
+ }
141
+
142
+ /**
143
+ * Handle /versions endpoint (Compact Index)
144
+ */
145
+ private async handleVersionsFile(): Promise<IResponse> {
146
+ const content = await this.storage.getRubyGemsVersions();
147
+
148
+ if (!content) {
149
+ return this.errorResponse(500, 'Versions file not initialized');
150
+ }
151
+
152
+ return {
153
+ status: 200,
154
+ headers: {
155
+ 'Content-Type': 'text/plain; charset=utf-8',
156
+ 'Cache-Control': 'public, max-age=60',
157
+ 'ETag': `"${await helpers.calculateMD5(content)}"`
158
+ },
159
+ body: Buffer.from(content),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Handle /names endpoint (Compact Index)
165
+ */
166
+ private async handleNamesFile(): Promise<IResponse> {
167
+ const content = await this.storage.getRubyGemsNames();
168
+
169
+ if (!content) {
170
+ return this.errorResponse(500, 'Names file not initialized');
171
+ }
172
+
173
+ return {
174
+ status: 200,
175
+ headers: {
176
+ 'Content-Type': 'text/plain; charset=utf-8',
177
+ 'Cache-Control': 'public, max-age=300'
178
+ },
179
+ body: Buffer.from(content),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Handle /info/{gem} endpoint (Compact Index)
185
+ */
186
+ private async handleInfoFile(gemName: string): Promise<IResponse> {
187
+ const content = await this.storage.getRubyGemsInfo(gemName);
188
+
189
+ if (!content) {
190
+ return {
191
+ status: 404,
192
+ headers: { 'Content-Type': 'text/plain' },
193
+ body: Buffer.from('Not Found'),
194
+ };
195
+ }
196
+
197
+ return {
198
+ status: 200,
199
+ headers: {
200
+ 'Content-Type': 'text/plain; charset=utf-8',
201
+ 'Cache-Control': 'public, max-age=300',
202
+ 'ETag': `"${await helpers.calculateMD5(content)}"`
203
+ },
204
+ body: Buffer.from(content),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Handle gem file download
210
+ */
211
+ private async handleDownload(filename: string): Promise<IResponse> {
212
+ const parsed = helpers.parseGemFilename(filename);
213
+ if (!parsed) {
214
+ return this.errorResponse(400, 'Invalid gem filename');
215
+ }
216
+
217
+ const gemData = await this.storage.getRubyGemsGem(
218
+ parsed.name,
219
+ parsed.version,
220
+ parsed.platform
221
+ );
222
+
223
+ if (!gemData) {
224
+ return this.errorResponse(404, 'Gem not found');
225
+ }
226
+
227
+ return {
228
+ status: 200,
229
+ headers: {
230
+ 'Content-Type': 'application/octet-stream',
231
+ 'Content-Disposition': `attachment; filename="${filename}"`,
232
+ 'Content-Length': gemData.length.toString()
233
+ },
234
+ body: gemData,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Handle API v1 requests
240
+ */
241
+ private async handleApiRequest(
242
+ path: string,
243
+ context: IRequestContext,
244
+ token: IAuthToken | null
245
+ ): Promise<IResponse> {
246
+ // Upload gem: POST /gems
247
+ if (path === '/gems' && context.method === 'POST') {
248
+ return this.handleUpload(context, token);
249
+ }
250
+
251
+ // Yank gem: DELETE /gems/yank
252
+ if (path === '/gems/yank' && context.method === 'DELETE') {
253
+ return this.handleYank(context, token);
254
+ }
255
+
256
+ // Unyank gem: PUT /gems/unyank
257
+ if (path === '/gems/unyank' && context.method === 'PUT') {
258
+ return this.handleUnyank(context, token);
259
+ }
260
+
261
+ // Version list: GET /versions/{gem}.json
262
+ const versionsMatch = path.match(/^\/versions\/([^\/]+)\.json$/);
263
+ if (versionsMatch && context.method === 'GET') {
264
+ return this.handleVersionsJson(versionsMatch[1]);
265
+ }
266
+
267
+ // Dependencies: GET /dependencies?gems={list}
268
+ if (path.startsWith('/dependencies') && context.method === 'GET') {
269
+ const gemsParam = context.query?.gems || '';
270
+ return this.handleDependencies(gemsParam);
271
+ }
272
+
273
+ return this.errorResponse(404, 'API endpoint not found');
274
+ }
275
+
276
+ /**
277
+ * Handle gem upload
278
+ * POST /api/v1/gems
279
+ */
280
+ private async handleUpload(context: IRequestContext, token: IAuthToken | null): Promise<IResponse> {
281
+ if (!token) {
282
+ return this.errorResponse(401, 'Authentication required');
283
+ }
284
+
285
+ try {
286
+ // Extract gem data from request body
287
+ const gemData = context.body as Buffer;
288
+ if (!gemData || gemData.length === 0) {
289
+ return this.errorResponse(400, 'No gem file provided');
290
+ }
291
+
292
+ // For now, we expect metadata in query params or headers
293
+ // Full implementation would parse .gem file (tar + gzip + Marshal)
294
+ const gemName = context.query?.name || context.headers['x-gem-name'];
295
+ const version = context.query?.version || context.headers['x-gem-version'];
296
+ const platform = context.query?.platform || context.headers['x-gem-platform'];
297
+
298
+ if (!gemName || !version) {
299
+ return this.errorResponse(400, 'Gem name and version required');
300
+ }
301
+
302
+ // Validate gem name
303
+ if (!helpers.isValidGemName(gemName)) {
304
+ return this.errorResponse(400, 'Invalid gem name');
305
+ }
306
+
307
+ // Check permission
308
+ if (!(await this.checkPermission(token, gemName, 'write'))) {
309
+ return this.errorResponse(403, 'Insufficient permissions');
310
+ }
311
+
312
+ // Calculate checksum
313
+ const checksum = await helpers.calculateSHA256(gemData);
314
+
315
+ // Store gem file
316
+ await this.storage.putRubyGemsGem(gemName, version, gemData, platform);
317
+
318
+ // Update metadata
319
+ let metadata: IRubyGemsMetadata = await this.storage.getRubyGemsMetadata(gemName) || {
320
+ name: gemName,
321
+ versions: {},
322
+ };
323
+
324
+ const versionKey = platform ? `${version}-${platform}` : version;
325
+ metadata.versions[versionKey] = {
326
+ version,
327
+ platform,
328
+ checksum,
329
+ size: gemData.length,
330
+ 'upload-time': new Date().toISOString(),
331
+ 'uploaded-by': token.userId,
332
+ dependencies: [], // Would extract from gem spec
333
+ requirements: [],
334
+ };
335
+
336
+ metadata['last-modified'] = new Date().toISOString();
337
+ await this.storage.putRubyGemsMetadata(gemName, metadata);
338
+
339
+ // Update Compact Index info file
340
+ await this.updateCompactIndexForGem(gemName, metadata);
341
+
342
+ // Update versions file
343
+ await this.updateVersionsFile(gemName, version, platform || 'ruby', false);
344
+
345
+ // Update names file
346
+ await this.updateNamesFile(gemName);
347
+
348
+ this.logger.log('info', `Gem uploaded: ${gemName} ${version}`, {
349
+ platform,
350
+ size: gemData.length
351
+ });
352
+
353
+ return {
354
+ status: 200,
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: Buffer.from(JSON.stringify({
357
+ message: 'Gem uploaded successfully',
358
+ name: gemName,
359
+ version,
360
+ })),
361
+ };
362
+ } catch (error) {
363
+ this.logger.log('error', 'Upload failed', { error: (error as Error).message });
364
+ return this.errorResponse(500, 'Upload failed: ' + (error as Error).message);
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Handle gem yanking
370
+ * DELETE /api/v1/gems/yank
371
+ */
372
+ private async handleYank(context: IRequestContext, token: IAuthToken | null): Promise<IResponse> {
373
+ if (!token) {
374
+ return this.errorResponse(401, 'Authentication required');
375
+ }
376
+
377
+ const gemName = context.query?.gem_name;
378
+ const version = context.query?.version;
379
+ const platform = context.query?.platform;
380
+
381
+ if (!gemName || !version) {
382
+ return this.errorResponse(400, 'Gem name and version required');
383
+ }
384
+
385
+ if (!(await this.checkPermission(token, gemName, 'yank'))) {
386
+ return this.errorResponse(403, 'Insufficient permissions');
387
+ }
388
+
389
+ // Update metadata to mark as yanked
390
+ const metadata = await this.storage.getRubyGemsMetadata(gemName);
391
+ if (!metadata) {
392
+ return this.errorResponse(404, 'Gem not found');
393
+ }
394
+
395
+ const versionKey = platform ? `${version}-${platform}` : version;
396
+ if (!metadata.versions[versionKey]) {
397
+ return this.errorResponse(404, 'Version not found');
398
+ }
399
+
400
+ metadata.versions[versionKey].yanked = true;
401
+ await this.storage.putRubyGemsMetadata(gemName, metadata);
402
+
403
+ // Update Compact Index
404
+ await this.updateCompactIndexForGem(gemName, metadata);
405
+ await this.updateVersionsFile(gemName, version, platform || 'ruby', true);
406
+
407
+ this.logger.log('info', `Gem yanked: ${gemName} ${version}`);
408
+
409
+ return {
410
+ status: 200,
411
+ headers: { 'Content-Type': 'application/json' },
412
+ body: Buffer.from(JSON.stringify({
413
+ success: true,
414
+ message: 'Gem yanked successfully'
415
+ })),
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Handle gem unyanking
421
+ * PUT /api/v1/gems/unyank
422
+ */
423
+ private async handleUnyank(context: IRequestContext, token: IAuthToken | null): Promise<IResponse> {
424
+ if (!token) {
425
+ return this.errorResponse(401, 'Authentication required');
426
+ }
427
+
428
+ const gemName = context.query?.gem_name;
429
+ const version = context.query?.version;
430
+ const platform = context.query?.platform;
431
+
432
+ if (!gemName || !version) {
433
+ return this.errorResponse(400, 'Gem name and version required');
434
+ }
435
+
436
+ if (!(await this.checkPermission(token, gemName, 'write'))) {
437
+ return this.errorResponse(403, 'Insufficient permissions');
438
+ }
439
+
440
+ const metadata = await this.storage.getRubyGemsMetadata(gemName);
441
+ if (!metadata) {
442
+ return this.errorResponse(404, 'Gem not found');
443
+ }
444
+
445
+ const versionKey = platform ? `${version}-${platform}` : version;
446
+ if (!metadata.versions[versionKey]) {
447
+ return this.errorResponse(404, 'Version not found');
448
+ }
449
+
450
+ metadata.versions[versionKey].yanked = false;
451
+ await this.storage.putRubyGemsMetadata(gemName, metadata);
452
+
453
+ // Update Compact Index
454
+ await this.updateCompactIndexForGem(gemName, metadata);
455
+ await this.updateVersionsFile(gemName, version, platform || 'ruby', false);
456
+
457
+ this.logger.log('info', `Gem unyanked: ${gemName} ${version}`);
458
+
459
+ return {
460
+ status: 200,
461
+ headers: { 'Content-Type': 'application/json' },
462
+ body: Buffer.from(JSON.stringify({
463
+ success: true,
464
+ message: 'Gem unyanked successfully'
465
+ })),
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Handle versions JSON API
471
+ */
472
+ private async handleVersionsJson(gemName: string): Promise<IResponse> {
473
+ const metadata = await this.storage.getRubyGemsMetadata(gemName);
474
+ if (!metadata) {
475
+ return this.errorResponse(404, 'Gem not found');
476
+ }
477
+
478
+ const versions = Object.values(metadata.versions).map((v: any) => ({
479
+ version: v.version,
480
+ platform: v.platform,
481
+ uploadTime: v['upload-time'],
482
+ }));
483
+
484
+ const response = helpers.generateVersionsJson(gemName, versions);
485
+
486
+ return {
487
+ status: 200,
488
+ headers: {
489
+ 'Content-Type': 'application/json',
490
+ 'Cache-Control': 'public, max-age=300'
491
+ },
492
+ body: Buffer.from(JSON.stringify(response)),
493
+ };
494
+ }
495
+
496
+ /**
497
+ * Handle dependencies query
498
+ */
499
+ private async handleDependencies(gemsParam: string): Promise<IResponse> {
500
+ const gemNames = gemsParam.split(',').filter(n => n.trim());
501
+ const result = new Map();
502
+
503
+ for (const gemName of gemNames) {
504
+ const metadata = await this.storage.getRubyGemsMetadata(gemName);
505
+ if (metadata) {
506
+ const versions = Object.values(metadata.versions).map((v: any) => ({
507
+ version: v.version,
508
+ platform: v.platform,
509
+ dependencies: v.dependencies || [],
510
+ }));
511
+ result.set(gemName, versions);
512
+ }
513
+ }
514
+
515
+ const response = helpers.generateDependenciesJson(result);
516
+
517
+ return {
518
+ status: 200,
519
+ headers: { 'Content-Type': 'application/json' },
520
+ body: Buffer.from(JSON.stringify(response)),
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Update Compact Index info file for a gem
526
+ */
527
+ private async updateCompactIndexForGem(
528
+ gemName: string,
529
+ metadata: IRubyGemsMetadata
530
+ ): Promise<void> {
531
+ const entries: ICompactIndexInfoEntry[] = Object.values(metadata.versions)
532
+ .filter(v => !v.yanked) // Exclude yanked from info file
533
+ .map(v => ({
534
+ version: v.version,
535
+ platform: v.platform,
536
+ dependencies: v.dependencies || [],
537
+ requirements: v.requirements || [],
538
+ checksum: v.checksum,
539
+ }));
540
+
541
+ const content = helpers.generateCompactIndexInfo(entries);
542
+ await this.storage.putRubyGemsInfo(gemName, content);
543
+ }
544
+
545
+ /**
546
+ * Update versions file with new/updated gem
547
+ */
548
+ private async updateVersionsFile(
549
+ gemName: string,
550
+ version: string,
551
+ platform: string,
552
+ yanked: boolean
553
+ ): Promise<void> {
554
+ const existingVersions = await this.storage.getRubyGemsVersions();
555
+ if (!existingVersions) return;
556
+
557
+ // Calculate info file checksum
558
+ const infoContent = await this.storage.getRubyGemsInfo(gemName) || '';
559
+ const infoChecksum = await helpers.calculateMD5(infoContent);
560
+
561
+ const updated = helpers.updateCompactIndexVersions(
562
+ existingVersions,
563
+ gemName,
564
+ { version, platform: platform !== 'ruby' ? platform : undefined, yanked },
565
+ infoChecksum
566
+ );
567
+
568
+ await this.storage.putRubyGemsVersions(updated);
569
+ }
570
+
571
+ /**
572
+ * Update names file with new gem
573
+ */
574
+ private async updateNamesFile(gemName: string): Promise<void> {
575
+ const existingNames = await this.storage.getRubyGemsNames();
576
+ if (!existingNames) return;
577
+
578
+ const lines = existingNames.split('\n').filter(l => l !== '---');
579
+ if (!lines.includes(gemName)) {
580
+ lines.push(gemName);
581
+ lines.sort();
582
+ const updated = helpers.generateNamesFile(lines);
583
+ await this.storage.putRubyGemsNames(updated);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Helper: Create error response
589
+ */
590
+ private errorResponse(status: number, message: string): IResponse {
591
+ const error: IRubyGemsError = { message, status };
592
+ return {
593
+ status,
594
+ headers: { 'Content-Type': 'application/json' },
595
+ body: Buffer.from(JSON.stringify(error)),
596
+ };
597
+ }
598
+ }