feat(core): Add PyPI and RubyGems protocol support, Cargo token management, and storage helpers
This commit is contained in:
@@ -317,12 +317,153 @@ export class AuthManager {
|
||||
this.tokenStore.delete(token);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CARGO TOKEN MANAGEMENT
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Create a Cargo token
|
||||
* @param userId - User ID
|
||||
* @param readonly - Whether the token is readonly
|
||||
* @returns Cargo UUID token
|
||||
*/
|
||||
public async createCargoToken(userId: string, readonly: boolean = false): Promise<string> {
|
||||
const scopes = readonly ? ['cargo:*:*:read'] : ['cargo:*:*:*'];
|
||||
return this.createUuidToken(userId, 'cargo', scopes, readonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Cargo token
|
||||
* @param token - Cargo UUID token
|
||||
* @returns Auth token object or null
|
||||
*/
|
||||
public async validateCargoToken(token: string): Promise<IAuthToken | null> {
|
||||
if (!this.isValidUuid(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authToken = this.tokenStore.get(token);
|
||||
if (!authToken || authToken.type !== 'cargo') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration if set
|
||||
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
|
||||
this.tokenStore.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a Cargo token
|
||||
* @param token - Cargo UUID token
|
||||
*/
|
||||
public async revokeCargoToken(token: string): Promise<void> {
|
||||
this.tokenStore.delete(token);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PYPI AUTHENTICATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Create a PyPI token
|
||||
* @param userId - User ID
|
||||
* @param readonly - Whether the token is readonly
|
||||
* @returns PyPI UUID token
|
||||
*/
|
||||
public async createPypiToken(userId: string, readonly: boolean = false): Promise<string> {
|
||||
const scopes = readonly ? ['pypi:*:*:read'] : ['pypi:*:*:*'];
|
||||
return this.createUuidToken(userId, 'pypi', scopes, readonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a PyPI token
|
||||
* @param token - PyPI UUID token
|
||||
* @returns Auth token object or null
|
||||
*/
|
||||
public async validatePypiToken(token: string): Promise<IAuthToken | null> {
|
||||
if (!this.isValidUuid(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authToken = this.tokenStore.get(token);
|
||||
if (!authToken || authToken.type !== 'pypi') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration if set
|
||||
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
|
||||
this.tokenStore.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a PyPI token
|
||||
* @param token - PyPI UUID token
|
||||
*/
|
||||
public async revokePypiToken(token: string): Promise<void> {
|
||||
this.tokenStore.delete(token);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// RUBYGEMS AUTHENTICATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Create a RubyGems token
|
||||
* @param userId - User ID
|
||||
* @param readonly - Whether the token is readonly
|
||||
* @returns RubyGems UUID token
|
||||
*/
|
||||
public async createRubyGemsToken(userId: string, readonly: boolean = false): Promise<string> {
|
||||
const scopes = readonly ? ['rubygems:*:*:read'] : ['rubygems:*:*:*'];
|
||||
return this.createUuidToken(userId, 'rubygems', scopes, readonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a RubyGems token
|
||||
* @param token - RubyGems UUID token
|
||||
* @returns Auth token object or null
|
||||
*/
|
||||
public async validateRubyGemsToken(token: string): Promise<IAuthToken | null> {
|
||||
if (!this.isValidUuid(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authToken = this.tokenStore.get(token);
|
||||
if (!authToken || authToken.type !== 'rubygems') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration if set
|
||||
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
|
||||
this.tokenStore.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a RubyGems token
|
||||
* @param token - RubyGems UUID token
|
||||
*/
|
||||
public async revokeRubyGemsToken(token: string): Promise<void> {
|
||||
this.tokenStore.delete(token);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// UNIFIED AUTHENTICATION
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Validate any token (NPM, Maven, or OCI)
|
||||
* Validate any token (NPM, Maven, OCI, PyPI, RubyGems, Composer, Cargo)
|
||||
* @param tokenString - Token string (UUID or JWT)
|
||||
* @param protocol - Expected protocol type
|
||||
* @returns Auth token object or null
|
||||
@@ -331,7 +472,7 @@ export class AuthManager {
|
||||
tokenString: string,
|
||||
protocol?: TRegistryProtocol
|
||||
): Promise<IAuthToken | null> {
|
||||
// Try UUID-based tokens (NPM, Maven, Composer)
|
||||
// Try UUID-based tokens (NPM, Maven, Composer, Cargo, PyPI, RubyGems)
|
||||
if (this.isValidUuid(tokenString)) {
|
||||
// Try NPM token
|
||||
const npmToken = await this.validateNpmToken(tokenString);
|
||||
@@ -350,6 +491,24 @@ export class AuthManager {
|
||||
if (composerToken && (!protocol || protocol === 'composer')) {
|
||||
return composerToken;
|
||||
}
|
||||
|
||||
// Try Cargo token
|
||||
const cargoToken = await this.validateCargoToken(tokenString);
|
||||
if (cargoToken && (!protocol || protocol === 'cargo')) {
|
||||
return cargoToken;
|
||||
}
|
||||
|
||||
// Try PyPI token
|
||||
const pypiToken = await this.validatePypiToken(tokenString);
|
||||
if (pypiToken && (!protocol || protocol === 'pypi')) {
|
||||
return pypiToken;
|
||||
}
|
||||
|
||||
// Try RubyGems token
|
||||
const rubygemsToken = await this.validateRubyGemsToken(tokenString);
|
||||
if (rubygemsToken && (!protocol || protocol === 'rubygems')) {
|
||||
return rubygemsToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Try OCI JWT
|
||||
|
||||
@@ -601,4 +601,231 @@ export class RegistryStorage implements IStorageBackend {
|
||||
private getComposerZipPath(vendorPackage: string, reference: string): string {
|
||||
return `composer/packages/${vendorPackage}/${reference}.zip`;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PYPI STORAGE METHODS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get PyPI package metadata
|
||||
*/
|
||||
public async getPypiPackageMetadata(packageName: string): Promise<any | null> {
|
||||
const path = this.getPypiMetadataPath(packageName);
|
||||
const data = await this.getObject(path);
|
||||
return data ? JSON.parse(data.toString('utf-8')) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store PyPI package metadata
|
||||
*/
|
||||
public async putPypiPackageMetadata(packageName: string, metadata: any): Promise<void> {
|
||||
const path = this.getPypiMetadataPath(packageName);
|
||||
const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
return this.putObject(path, data, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PyPI package metadata exists
|
||||
*/
|
||||
public async pypiPackageMetadataExists(packageName: string): Promise<boolean> {
|
||||
const path = this.getPypiMetadataPath(packageName);
|
||||
return this.objectExists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete PyPI package metadata
|
||||
*/
|
||||
public async deletePypiPackageMetadata(packageName: string): Promise<void> {
|
||||
const path = this.getPypiMetadataPath(packageName);
|
||||
return this.deleteObject(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PyPI Simple API index (HTML)
|
||||
*/
|
||||
public async getPypiSimpleIndex(packageName: string): Promise<string | null> {
|
||||
const path = this.getPypiSimpleIndexPath(packageName);
|
||||
const data = await this.getObject(path);
|
||||
return data ? data.toString('utf-8') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store PyPI Simple API index (HTML)
|
||||
*/
|
||||
public async putPypiSimpleIndex(packageName: string, html: string): Promise<void> {
|
||||
const path = this.getPypiSimpleIndexPath(packageName);
|
||||
const data = Buffer.from(html, 'utf-8');
|
||||
return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PyPI root Simple API index (HTML)
|
||||
*/
|
||||
public async getPypiSimpleRootIndex(): Promise<string | null> {
|
||||
const path = this.getPypiSimpleRootIndexPath();
|
||||
const data = await this.getObject(path);
|
||||
return data ? data.toString('utf-8') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store PyPI root Simple API index (HTML)
|
||||
*/
|
||||
public async putPypiSimpleRootIndex(html: string): Promise<void> {
|
||||
const path = this.getPypiSimpleRootIndexPath();
|
||||
const data = Buffer.from(html, 'utf-8');
|
||||
return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PyPI package file (wheel, sdist)
|
||||
*/
|
||||
public async getPypiPackageFile(packageName: string, filename: string): Promise<Buffer | null> {
|
||||
const path = this.getPypiPackageFilePath(packageName, filename);
|
||||
return this.getObject(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store PyPI package file (wheel, sdist)
|
||||
*/
|
||||
public async putPypiPackageFile(
|
||||
packageName: string,
|
||||
filename: string,
|
||||
data: Buffer
|
||||
): Promise<void> {
|
||||
const path = this.getPypiPackageFilePath(packageName, filename);
|
||||
return this.putObject(path, data, { 'Content-Type': 'application/octet-stream' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PyPI package file exists
|
||||
*/
|
||||
public async pypiPackageFileExists(packageName: string, filename: string): Promise<boolean> {
|
||||
const path = this.getPypiPackageFilePath(packageName, filename);
|
||||
return this.objectExists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete PyPI package file
|
||||
*/
|
||||
public async deletePypiPackageFile(packageName: string, filename: string): Promise<void> {
|
||||
const path = this.getPypiPackageFilePath(packageName, filename);
|
||||
return this.deleteObject(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all PyPI packages
|
||||
*/
|
||||
public async listPypiPackages(): Promise<string[]> {
|
||||
const prefix = 'pypi/metadata/';
|
||||
const objects = await this.listObjects(prefix);
|
||||
const packages = new Set<string>();
|
||||
|
||||
// Extract package names from paths like: pypi/metadata/package-name/metadata.json
|
||||
for (const obj of objects) {
|
||||
const match = obj.match(/^pypi\/metadata\/([^\/]+)\/metadata\.json$/);
|
||||
if (match) {
|
||||
packages.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(packages).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all versions of a PyPI package
|
||||
*/
|
||||
public async listPypiPackageVersions(packageName: string): Promise<string[]> {
|
||||
const prefix = `pypi/packages/${packageName}/`;
|
||||
const objects = await this.listObjects(prefix);
|
||||
const versions = new Set<string>();
|
||||
|
||||
// Extract versions from filenames
|
||||
for (const obj of objects) {
|
||||
const filename = obj.split('/').pop();
|
||||
if (!filename) continue;
|
||||
|
||||
// Extract version from wheel filename: package-1.0.0-py3-none-any.whl
|
||||
// or sdist filename: package-1.0.0.tar.gz
|
||||
const wheelMatch = filename.match(/^[^-]+-([^-]+)-.*\.whl$/);
|
||||
const sdistMatch = filename.match(/^[^-]+-([^.]+)\.(tar\.gz|zip)$/);
|
||||
|
||||
if (wheelMatch) versions.add(wheelMatch[1]);
|
||||
else if (sdistMatch) versions.add(sdistMatch[1]);
|
||||
}
|
||||
|
||||
return Array.from(versions).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entire PyPI package (all versions and files)
|
||||
*/
|
||||
public async deletePypiPackage(packageName: string): Promise<void> {
|
||||
// Delete metadata
|
||||
await this.deletePypiPackageMetadata(packageName);
|
||||
|
||||
// Delete Simple API index
|
||||
const simpleIndexPath = this.getPypiSimpleIndexPath(packageName);
|
||||
try {
|
||||
await this.deleteObject(simpleIndexPath);
|
||||
} catch (error) {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
|
||||
// Delete all package files
|
||||
const prefix = `pypi/packages/${packageName}/`;
|
||||
const objects = await this.listObjects(prefix);
|
||||
for (const obj of objects) {
|
||||
await this.deleteObject(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific version of a PyPI package
|
||||
*/
|
||||
public async deletePypiPackageVersion(packageName: string, version: string): Promise<void> {
|
||||
const prefix = `pypi/packages/${packageName}/`;
|
||||
const objects = await this.listObjects(prefix);
|
||||
|
||||
// Delete all files matching this version
|
||||
for (const obj of objects) {
|
||||
const filename = obj.split('/').pop();
|
||||
if (!filename) continue;
|
||||
|
||||
// Check if filename contains this version
|
||||
const wheelMatch = filename.match(/^[^-]+-([^-]+)-.*\.whl$/);
|
||||
const sdistMatch = filename.match(/^[^-]+-([^.]+)\.(tar\.gz|zip)$/);
|
||||
|
||||
const fileVersion = wheelMatch?.[1] || sdistMatch?.[1];
|
||||
if (fileVersion === version) {
|
||||
await this.deleteObject(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata to remove this version
|
||||
const metadata = await this.getPypiPackageMetadata(packageName);
|
||||
if (metadata && metadata.versions) {
|
||||
delete metadata.versions[version];
|
||||
await this.putPypiPackageMetadata(packageName, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PYPI PATH HELPERS
|
||||
// ========================================================================
|
||||
|
||||
private getPypiMetadataPath(packageName: string): string {
|
||||
return `pypi/metadata/${packageName}/metadata.json`;
|
||||
}
|
||||
|
||||
private getPypiSimpleIndexPath(packageName: string): string {
|
||||
return `pypi/simple/${packageName}/index.html`;
|
||||
}
|
||||
|
||||
private getPypiSimpleRootIndexPath(): string {
|
||||
return `pypi/simple/index.html`;
|
||||
}
|
||||
|
||||
private getPypiPackageFilePath(packageName: string, filename: string): string {
|
||||
return `pypi/packages/${packageName}/${filename}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* Registry protocol types
|
||||
*/
|
||||
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer';
|
||||
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
|
||||
|
||||
/**
|
||||
* Unified action types across protocols
|
||||
@@ -70,6 +70,16 @@ export interface IAuthConfig {
|
||||
realm: string;
|
||||
service: string;
|
||||
};
|
||||
/** PyPI token settings */
|
||||
pypiTokens?: {
|
||||
enabled: boolean;
|
||||
defaultReadonly?: boolean;
|
||||
};
|
||||
/** RubyGems token settings */
|
||||
rubygemsTokens?: {
|
||||
enabled: boolean;
|
||||
defaultReadonly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +102,8 @@ export interface IRegistryConfig {
|
||||
maven?: IProtocolConfig;
|
||||
cargo?: IProtocolConfig;
|
||||
composer?: IProtocolConfig;
|
||||
pypi?: IProtocolConfig;
|
||||
rubygems?: IProtocolConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user