@push.rocks/smartregistry 1.4.1 → 1.5.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.hints.md CHANGED
@@ -1,3 +1,335 @@
1
1
  # Project Readme Hints
2
2
 
3
- This is the initial readme hints file.
3
+ ## Python (PyPI) Protocol Implementation Notes
4
+
5
+ ### PEP 503: Simple Repository API (HTML-based)
6
+
7
+ **URL Structure:**
8
+ - Root: `/<base>/` - Lists all projects
9
+ - Project: `/<base>/<project>/` - Lists all files for a project
10
+ - All URLs MUST end with `/` (redirect if missing)
11
+
12
+ **Package Name Normalization:**
13
+ - Lowercase all characters
14
+ - Replace runs of `.`, `-`, `_` with single `-`
15
+ - Implementation: `re.sub(r"[-_.]+", "-", name).lower()`
16
+
17
+ **HTML Format:**
18
+ - Root: One anchor per project
19
+ - Project: One anchor per file
20
+ - Anchor text must match final filename
21
+ - Anchor href links to download URL
22
+
23
+ **Hash Fragments:**
24
+ Format: `#<hashname>=<hashvalue>`
25
+ - hashname: lowercase hash function name (recommend `sha256`)
26
+ - hashvalue: hex-encoded digest
27
+
28
+ **Data Attributes:**
29
+ - `data-gpg-sig`: `true`/`false` for GPG signature presence
30
+ - `data-requires-python`: PEP 345 requirement string (HTML-encode `<` as `&lt;`, `>` as `&gt;`)
31
+
32
+ ### PEP 691: JSON-based Simple API
33
+
34
+ **Content Types:**
35
+ - `application/vnd.pypi.simple.v1+json` - JSON format
36
+ - `application/vnd.pypi.simple.v1+html` - HTML format
37
+ - `text/html` - Alias for HTML (backwards compat)
38
+
39
+ **Root Endpoint JSON:**
40
+ ```json
41
+ {
42
+ "meta": {"api-version": "1.0"},
43
+ "projects": [{"name": "ProjectName"}]
44
+ }
45
+ ```
46
+
47
+ **Project Endpoint JSON:**
48
+ ```json
49
+ {
50
+ "name": "normalized-name",
51
+ "meta": {"api-version": "1.0"},
52
+ "files": [
53
+ {
54
+ "filename": "package-1.0-py3-none-any.whl",
55
+ "url": "https://example.com/path/to/file",
56
+ "hashes": {"sha256": "..."},
57
+ "requires-python": ">=3.7",
58
+ "dist-info-metadata": true | {"sha256": "..."},
59
+ "gpg-sig": true,
60
+ "yanked": false | "reason string"
61
+ }
62
+ ]
63
+ }
64
+ ```
65
+
66
+ **Content Negotiation:**
67
+ - Use `Accept` header for format selection
68
+ - Server responds with `Content-Type` header
69
+ - Support both JSON and HTML formats
70
+
71
+ ### PyPI Upload API (Legacy /legacy/)
72
+
73
+ **Endpoint:**
74
+ - URL: `https://upload.pypi.org/legacy/`
75
+ - Method: `POST`
76
+ - Content-Type: `multipart/form-data`
77
+
78
+ **Required Form Fields:**
79
+ - `:action` = `file_upload`
80
+ - `protocol_version` = `1`
81
+ - `content` = Binary file data with filename
82
+ - `filetype` = `bdist_wheel` | `sdist`
83
+ - `pyversion` = Python tag (e.g., `py3`, `py2.py3`) or `source` for sdist
84
+ - `metadata_version` = Metadata standard version
85
+ - `name` = Package name
86
+ - `version` = Version string
87
+
88
+ **Hash Digest (one required):**
89
+ - `md5_digest`: urlsafe base64 without padding
90
+ - `sha256_digest`: hexadecimal
91
+ - `blake2_256_digest`: hexadecimal
92
+
93
+ **Optional Fields:**
94
+ - `attestations`: JSON array of attestation objects
95
+ - Any Core Metadata fields (lowercase, hyphens → underscores)
96
+ - Example: `Description-Content-Type` → `description_content_type`
97
+
98
+ **Authentication:**
99
+ - Username/password or API token in HTTP Basic Auth
100
+ - API tokens: username = `__token__`, password = token value
101
+
102
+ **Behavior:**
103
+ - First file uploaded creates the release
104
+ - Multiple files uploaded sequentially for same version
105
+
106
+ ### PEP 694: Upload 2.0 API
107
+
108
+ **Status:** Draft (not yet required, legacy API still supported)
109
+ - Multi-step workflow with sessions
110
+ - Async upload support with resumption
111
+ - JSON-based API
112
+ - Standard HTTP auth (RFC 7235)
113
+ - Not implementing initially (legacy API sufficient)
114
+
115
+ ---
116
+
117
+ ## Ruby (RubyGems) Protocol Implementation Notes
118
+
119
+ ### Compact Index Format
120
+
121
+ **Endpoints:**
122
+ - `/versions` - Master list of all gems and versions
123
+ - `/info/<RUBYGEM>` - Detailed info for specific gem
124
+ - `/names` - Simple list of gem names
125
+
126
+ **Authentication:**
127
+ - UUID tokens similar to NPM pattern
128
+ - API key in `Authorization` header
129
+ - Scope format: `rubygems:gem:{name}:{read|write|yank}`
130
+
131
+ ### `/versions` File Format
132
+
133
+ **Structure:**
134
+ ```
135
+ created_at: 2024-04-01T00:00:05Z
136
+ ---
137
+ RUBYGEM [-]VERSION_PLATFORM[,VERSION_PLATFORM,...] MD5
138
+ ```
139
+
140
+ **Details:**
141
+ - Metadata lines before `---` delimiter
142
+ - One line per gem with comma-separated versions
143
+ - `[-]` prefix indicates yanked version
144
+ - `MD5`: Checksum of corresponding `/info/<RUBYGEM>` file
145
+ - Append-only during month, recalculated monthly
146
+
147
+ ### `/info/<RUBYGEM>` File Format
148
+
149
+ **Structure:**
150
+ ```
151
+ ---
152
+ VERSION[-PLATFORM] [DEPENDENCY[,DEPENDENCY,...]]|REQUIREMENT[,REQUIREMENT,...]
153
+ ```
154
+
155
+ **Dependency Format:**
156
+ ```
157
+ GEM:CONSTRAINT[&CONSTRAINT]
158
+ ```
159
+ - Examples: `actionmailer:= 2.2.2`, `parser:>= 3.2.2.3`
160
+ - Operators: `=`, `>`, `<`, `>=`, `<=`, `~>`, `!=`
161
+ - Multiple constraints: `unicode-display_width:< 3.0&>= 2.4.0`
162
+
163
+ **Requirement Format:**
164
+ ```
165
+ checksum:SHA256_HEX
166
+ ruby:CONSTRAINT
167
+ rubygems:CONSTRAINT
168
+ ```
169
+
170
+ **Platform:**
171
+ - Default platform is `ruby`
172
+ - Non-default platforms: `VERSION-PLATFORM` (e.g., `3.2.1-arm64-darwin`)
173
+
174
+ **Yanked Gems:**
175
+ - Listed with `-` prefix in `/versions`
176
+ - Excluded entirely from `/info/<RUBYGEM>` file
177
+
178
+ ### `/names` File Format
179
+
180
+ ```
181
+ ---
182
+ gemname1
183
+ gemname2
184
+ gemname3
185
+ ```
186
+
187
+ ### HTTP Range Support
188
+
189
+ **Headers:**
190
+ - `Range: bytes=#{start}-`: Request from byte position
191
+ - `If-None-Match`: ETag conditional request
192
+ - `Repr-Digest`: SHA256 checksum in response
193
+
194
+ **Caching Strategy:**
195
+ 1. Store file with last byte position
196
+ 2. Request range from last position
197
+ 3. Append response to existing file
198
+ 4. Verify SHA256 against `Repr-Digest`
199
+
200
+ ### RubyGems Upload/Management API
201
+
202
+ **Upload Gem:**
203
+ - `POST /api/v1/gems`
204
+ - Binary `.gem` file in request body
205
+ - `Authorization` header with API key
206
+
207
+ **Yank Version:**
208
+ - `DELETE /api/v1/gems/yank`
209
+ - Parameters: `gem_name`, `version`
210
+
211
+ **Unyank Version:**
212
+ - `PUT /api/v1/gems/unyank`
213
+ - Parameters: `gem_name`, `version`
214
+
215
+ **Version Metadata:**
216
+ - `GET /api/v1/versions/<gem>.json`
217
+ - Returns JSON array of versions
218
+
219
+ **Dependencies:**
220
+ - `GET /api/v1/dependencies?gems=<comma-list>`
221
+ - Returns dependency information for resolution
222
+
223
+ ---
224
+
225
+ ## Implementation Strategy
226
+
227
+ ### Storage Paths
228
+
229
+ **PyPI:**
230
+ ```
231
+ pypi/
232
+ ├── simple/ # PEP 503 HTML files
233
+ │ ├── index.html # All packages list
234
+ │ └── {package}/index.html # Package versions list
235
+ ├── packages/
236
+ │ └── {package}/{filename} # .whl and .tar.gz files
237
+ └── metadata/
238
+ └── {package}/metadata.json # Package metadata
239
+ ```
240
+
241
+ **RubyGems:**
242
+ ```
243
+ rubygems/
244
+ ├── versions # Master versions file
245
+ ├── info/{gemname} # Per-gem info files
246
+ ├── names # All gem names
247
+ └── gems/{gemname}-{version}.gem # .gem files
248
+ ```
249
+
250
+ ### Authentication Pattern
251
+
252
+ Both protocols should follow the existing UUID token pattern used by NPM, Maven, Cargo, Composer:
253
+
254
+ ```typescript
255
+ // AuthManager additions
256
+ createPypiToken(userId: string, readonly: boolean): string
257
+ validatePypiToken(token: string): ITokenInfo | null
258
+ revokePypiToken(token: string): boolean
259
+
260
+ createRubyGemsToken(userId: string, readonly: boolean): string
261
+ validateRubyGemsToken(token: string): ITokenInfo | null
262
+ revokeRubyGemsToken(token: string): boolean
263
+ ```
264
+
265
+ ### Scope Format
266
+
267
+ ```
268
+ pypi:package:{name}:{read|write}
269
+ rubygems:gem:{name}:{read|write|yank}
270
+ ```
271
+
272
+ ### Common Patterns
273
+
274
+ 1. **Package name normalization** - Critical for PyPI
275
+ 2. **Checksum calculation** - SHA256 for both protocols
276
+ 3. **Append-only files** - RubyGems compact index
277
+ 4. **Content negotiation** - PyPI JSON vs HTML
278
+ 5. **Multipart upload parsing** - PyPI file uploads
279
+ 6. **Binary file handling** - Both protocols (.whl, .tar.gz, .gem)
280
+
281
+ ---
282
+
283
+ ## Key Differences from Existing Protocols
284
+
285
+ **PyPI vs NPM:**
286
+ - PyPI uses Simple API (HTML) + JSON API
287
+ - PyPI requires package name normalization
288
+ - PyPI uses multipart form data for uploads (not JSON)
289
+ - PyPI supports multiple file types per release (wheel + sdist)
290
+
291
+ **RubyGems vs Cargo:**
292
+ - RubyGems uses compact index (append-only text files)
293
+ - RubyGems uses checksums in index files (not just filenames)
294
+ - RubyGems has HTTP Range support for incremental updates
295
+ - RubyGems uses MD5 for index checksums, SHA256 for .gem files
296
+
297
+ ---
298
+
299
+ ## Testing Requirements
300
+
301
+ ### PyPI Tests Must Cover:
302
+ - Package upload (wheel and sdist)
303
+ - Package name normalization
304
+ - Simple API HTML generation (PEP 503)
305
+ - JSON API responses (PEP 691)
306
+ - Content negotiation
307
+ - Hash calculation and verification
308
+ - Authentication (tokens)
309
+ - Multi-file releases
310
+ - Yanked packages
311
+
312
+ ### RubyGems Tests Must Cover:
313
+ - Gem upload
314
+ - Compact index generation
315
+ - `/versions` file updates (append-only)
316
+ - `/info/<gem>` file generation
317
+ - `/names` file generation
318
+ - Checksum calculations (MD5 and SHA256)
319
+ - Platform-specific gems
320
+ - Yanking/unyanking
321
+ - HTTP Range requests
322
+ - Authentication (API keys)
323
+
324
+ ---
325
+
326
+ ## Security Considerations
327
+
328
+ 1. **Package name validation** - Prevent path traversal
329
+ 2. **File size limits** - Prevent DoS via large uploads
330
+ 3. **Content-Type validation** - Verify file types
331
+ 4. **Checksum verification** - Ensure file integrity
332
+ 5. **Token scope enforcement** - Read vs write permissions
333
+ 6. **HTML escaping** - Prevent XSS in generated HTML
334
+ 7. **Metadata sanitization** - Clean user-provided strings
335
+ 8. **Rate limiting** - Consider upload frequency limits
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartregistry',
6
- version: '1.4.1',
6
+ version: '1.5.0',
7
7
  description: 'a registry for npm modules and oci images'
8
8
  }
@@ -317,12 +317,153 @@ export class AuthManager {
317
317
  this.tokenStore.delete(token);
318
318
  }
319
319
 
320
+ // ========================================================================
321
+ // CARGO TOKEN MANAGEMENT
322
+ // ========================================================================
323
+
324
+ /**
325
+ * Create a Cargo token
326
+ * @param userId - User ID
327
+ * @param readonly - Whether the token is readonly
328
+ * @returns Cargo UUID token
329
+ */
330
+ public async createCargoToken(userId: string, readonly: boolean = false): Promise<string> {
331
+ const scopes = readonly ? ['cargo:*:*:read'] : ['cargo:*:*:*'];
332
+ return this.createUuidToken(userId, 'cargo', scopes, readonly);
333
+ }
334
+
335
+ /**
336
+ * Validate a Cargo token
337
+ * @param token - Cargo UUID token
338
+ * @returns Auth token object or null
339
+ */
340
+ public async validateCargoToken(token: string): Promise<IAuthToken | null> {
341
+ if (!this.isValidUuid(token)) {
342
+ return null;
343
+ }
344
+
345
+ const authToken = this.tokenStore.get(token);
346
+ if (!authToken || authToken.type !== 'cargo') {
347
+ return null;
348
+ }
349
+
350
+ // Check expiration if set
351
+ if (authToken.expiresAt && authToken.expiresAt < new Date()) {
352
+ this.tokenStore.delete(token);
353
+ return null;
354
+ }
355
+
356
+ return authToken;
357
+ }
358
+
359
+ /**
360
+ * Revoke a Cargo token
361
+ * @param token - Cargo UUID token
362
+ */
363
+ public async revokeCargoToken(token: string): Promise<void> {
364
+ this.tokenStore.delete(token);
365
+ }
366
+
367
+ // ========================================================================
368
+ // PYPI AUTHENTICATION
369
+ // ========================================================================
370
+
371
+ /**
372
+ * Create a PyPI token
373
+ * @param userId - User ID
374
+ * @param readonly - Whether the token is readonly
375
+ * @returns PyPI UUID token
376
+ */
377
+ public async createPypiToken(userId: string, readonly: boolean = false): Promise<string> {
378
+ const scopes = readonly ? ['pypi:*:*:read'] : ['pypi:*:*:*'];
379
+ return this.createUuidToken(userId, 'pypi', scopes, readonly);
380
+ }
381
+
382
+ /**
383
+ * Validate a PyPI token
384
+ * @param token - PyPI UUID token
385
+ * @returns Auth token object or null
386
+ */
387
+ public async validatePypiToken(token: string): Promise<IAuthToken | null> {
388
+ if (!this.isValidUuid(token)) {
389
+ return null;
390
+ }
391
+
392
+ const authToken = this.tokenStore.get(token);
393
+ if (!authToken || authToken.type !== 'pypi') {
394
+ return null;
395
+ }
396
+
397
+ // Check expiration if set
398
+ if (authToken.expiresAt && authToken.expiresAt < new Date()) {
399
+ this.tokenStore.delete(token);
400
+ return null;
401
+ }
402
+
403
+ return authToken;
404
+ }
405
+
406
+ /**
407
+ * Revoke a PyPI token
408
+ * @param token - PyPI UUID token
409
+ */
410
+ public async revokePypiToken(token: string): Promise<void> {
411
+ this.tokenStore.delete(token);
412
+ }
413
+
414
+ // ========================================================================
415
+ // RUBYGEMS AUTHENTICATION
416
+ // ========================================================================
417
+
418
+ /**
419
+ * Create a RubyGems token
420
+ * @param userId - User ID
421
+ * @param readonly - Whether the token is readonly
422
+ * @returns RubyGems UUID token
423
+ */
424
+ public async createRubyGemsToken(userId: string, readonly: boolean = false): Promise<string> {
425
+ const scopes = readonly ? ['rubygems:*:*:read'] : ['rubygems:*:*:*'];
426
+ return this.createUuidToken(userId, 'rubygems', scopes, readonly);
427
+ }
428
+
429
+ /**
430
+ * Validate a RubyGems token
431
+ * @param token - RubyGems UUID token
432
+ * @returns Auth token object or null
433
+ */
434
+ public async validateRubyGemsToken(token: string): Promise<IAuthToken | null> {
435
+ if (!this.isValidUuid(token)) {
436
+ return null;
437
+ }
438
+
439
+ const authToken = this.tokenStore.get(token);
440
+ if (!authToken || authToken.type !== 'rubygems') {
441
+ return null;
442
+ }
443
+
444
+ // Check expiration if set
445
+ if (authToken.expiresAt && authToken.expiresAt < new Date()) {
446
+ this.tokenStore.delete(token);
447
+ return null;
448
+ }
449
+
450
+ return authToken;
451
+ }
452
+
453
+ /**
454
+ * Revoke a RubyGems token
455
+ * @param token - RubyGems UUID token
456
+ */
457
+ public async revokeRubyGemsToken(token: string): Promise<void> {
458
+ this.tokenStore.delete(token);
459
+ }
460
+
320
461
  // ========================================================================
321
462
  // UNIFIED AUTHENTICATION
322
463
  // ========================================================================
323
464
 
324
465
  /**
325
- * Validate any token (NPM, Maven, or OCI)
466
+ * Validate any token (NPM, Maven, OCI, PyPI, RubyGems, Composer, Cargo)
326
467
  * @param tokenString - Token string (UUID or JWT)
327
468
  * @param protocol - Expected protocol type
328
469
  * @returns Auth token object or null
@@ -331,7 +472,7 @@ export class AuthManager {
331
472
  tokenString: string,
332
473
  protocol?: TRegistryProtocol
333
474
  ): Promise<IAuthToken | null> {
334
- // Try UUID-based tokens (NPM, Maven, Composer)
475
+ // Try UUID-based tokens (NPM, Maven, Composer, Cargo, PyPI, RubyGems)
335
476
  if (this.isValidUuid(tokenString)) {
336
477
  // Try NPM token
337
478
  const npmToken = await this.validateNpmToken(tokenString);
@@ -350,6 +491,24 @@ export class AuthManager {
350
491
  if (composerToken && (!protocol || protocol === 'composer')) {
351
492
  return composerToken;
352
493
  }
494
+
495
+ // Try Cargo token
496
+ const cargoToken = await this.validateCargoToken(tokenString);
497
+ if (cargoToken && (!protocol || protocol === 'cargo')) {
498
+ return cargoToken;
499
+ }
500
+
501
+ // Try PyPI token
502
+ const pypiToken = await this.validatePypiToken(tokenString);
503
+ if (pypiToken && (!protocol || protocol === 'pypi')) {
504
+ return pypiToken;
505
+ }
506
+
507
+ // Try RubyGems token
508
+ const rubygemsToken = await this.validateRubyGemsToken(tokenString);
509
+ if (rubygemsToken && (!protocol || protocol === 'rubygems')) {
510
+ return rubygemsToken;
511
+ }
353
512
  }
354
513
 
355
514
  // Try OCI JWT