feat(registry): add declarative protocol routing and request-scoped storage hook context across registries

This commit is contained in:
2026-04-16 10:42:33 +00:00
parent 09335d41f3
commit 9643ef98b9
28 changed files with 2327 additions and 1919 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 2026-04-16 - 2.9.0 - feat(registry)
add declarative protocol routing and request-scoped storage hook context across registries
- Refactors protocol registration and request dispatch in SmartRegistry around shared registry descriptors.
- Wraps protocol request handling in storage context so hooks receive protocol, actor, package, and version metadata without cross-request leakage.
- Adds shared base registry helpers for header parsing, bearer/basic auth extraction, actor construction, and protocol logger creation.
- Improves NPM route parsing and publish helpers, including support for unencoded scoped package metadata and publish paths.
- Introduces centralized registry storage path helpers and expands test helpers and coverage for concurrent context isolation and real request hook metadata.
## 2026-03-27 - 2.8.2 - fix(maven,tests)
handle Maven Basic auth and accept deploy-plugin metadata/checksum uploads while stabilizing npm CLI test cleanup
+26
View File
@@ -470,89 +470,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1125,36 +1141,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.11':
resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.11':
resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.11':
resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==}
@@ -1196,21 +1218,25 @@ packages:
resolution: {integrity: sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rspack/binding-linux-arm64-musl@1.7.10':
resolution: {integrity: sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rspack/binding-linux-x64-gnu@1.7.10':
resolution: {integrity: sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rspack/binding-linux-x64-musl@1.7.10':
resolution: {integrity: sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rspack/binding-wasm32-wasi@1.7.10':
resolution: {integrity: sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==}
+5
View File
@@ -22,6 +22,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Shared Storage**: Cloud-agnostic S3-compatible backend via [@push.rocks/smartbucket](https://www.npmjs.com/package/@push.rocks/smartbucket) with standardized `IS3Descriptor` from [@tsclass/tsclass](https://www.npmjs.com/package/@tsclass/tsclass)
- **Unified Authentication**: Scope-based permissions across all protocols
- **Path-based Routing**: `/oci/*`, `/npm/*`, `/maven/*`, `/cargo/*`, `/composer/*`, `/pypi/*`, `/rubygems/*`
- **Declarative Protocol Wiring**: Protocol registration, initialization, and routing stay aligned through shared descriptors
### 🔐 Authentication & Authorization
- NPM UUID tokens for package operations
@@ -60,6 +61,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🔌 Enterprise Extensibility
- **Storage Event Hooks** (`IStorageHooks`): Quota tracking, audit logging, virus scanning, cache invalidation
- **Request Actor Context**: Pass user/org info through requests for audit trails and rate limiting
- **Request-Scoped Hook Metadata**: Hooks receive protocol, actor, package, and version context without cross-request leakage
## 📥 Installation
@@ -233,6 +235,9 @@ const search = await registry.handleRequest({
});
```
Scoped package requests are supported with both encoded and unencoded paths, for example
`/npm/@scope%2fpackage` and `/npm/@scope/package`.
### 🦀 Cargo Registry (Rust Crates)
```typescript
+420
View File
@@ -0,0 +1,420 @@
import * as crypto from 'crypto';
import * as smartarchive from '@push.rocks/smartarchive';
/**
* Helper to calculate SHA-256 digest in OCI format
*/
export function calculateDigest(data: Buffer): string {
const hash = crypto.createHash('sha256').update(data).digest('hex');
return `sha256:${hash}`;
}
/**
* Helper to create a minimal valid OCI manifest
*/
export function createTestManifest(configDigest: string, layerDigest: string) {
return {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
config: {
mediaType: 'application/vnd.oci.image.config.v1+json',
size: 123,
digest: configDigest,
},
layers: [
{
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
size: 456,
digest: layerDigest,
},
],
};
}
/**
* Helper to create a minimal valid NPM packument
*/
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
return {
name: packageName,
versions: {
[version]: {
name: packageName,
version: version,
description: 'Test package',
main: 'index.js',
scripts: {},
dist: {
shasum: shasum,
integrity: integrity,
tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`,
},
},
},
'dist-tags': {
latest: version,
},
_attachments: {
[`${packageName}-${version}.tgz`]: {
content_type: 'application/octet-stream',
data: tarballData.toString('base64'),
length: tarballData.length,
},
},
};
}
/**
* Helper to create a minimal valid Maven POM file
*/
export function createTestPom(
groupId: string,
artifactId: string,
version: string,
packaging: string = 'jar'
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>${groupId}</groupId>
<artifactId>${artifactId}</artifactId>
<version>${version}</version>
<packaging>${packaging}</packaging>
<name>${artifactId}</name>
<description>Test Maven artifact</description>
</project>`;
}
/**
* Helper to create a test JAR file (minimal ZIP with manifest)
*/
export function createTestJar(): Buffer {
const manifestContent = `Manifest-Version: 1.0
Created-By: SmartRegistry Test
`;
return Buffer.from(manifestContent, 'utf-8');
}
/**
* Helper to calculate Maven checksums
*/
export function calculateMavenChecksums(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha1: crypto.createHash('sha1').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
sha512: crypto.createHash('sha512').update(data).digest('hex'),
};
}
/**
* Helper to create a Composer package ZIP using smartarchive
*/
export async function createComposerZip(
vendorPackage: string,
version: string,
options?: {
description?: string;
license?: string[];
authors?: Array<{ name: string; email?: string }>;
}
): Promise<Buffer> {
const zipTools = new smartarchive.ZipTools();
const composerJson = {
name: vendorPackage,
version: version,
type: 'library',
description: options?.description || 'Test Composer package',
license: options?.license || ['MIT'],
authors: options?.authors || [{ name: 'Test Author', email: 'test@example.com' }],
require: {
php: '>=7.4',
},
autoload: {
'psr-4': {
'Vendor\\TestPackage\\': 'src/',
},
},
};
const [vendor, pkg] = vendorPackage.split('/');
const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`;
const testPhpContent = `<?php
namespace ${namespace};
class TestClass
{
public function greet(): string
{
return "Hello from ${vendorPackage}!";
}
}
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: 'composer.json',
content: Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'),
},
{
archivePath: 'src/TestClass.php',
content: Buffer.from(testPhpContent, 'utf-8'),
},
{
archivePath: 'README.md',
content: Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'),
},
];
return Buffer.from(await zipTools.createZip(entries));
}
/**
* Helper to create a test Python wheel file (minimal ZIP structure) using smartarchive
*/
export async function createPythonWheel(
packageName: string,
version: string,
pyVersion: string = 'py3'
): Promise<Buffer> {
const zipTools = new smartarchive.ZipTools();
const normalizedName = packageName.replace(/-/g, '_');
const distInfoDir = `${normalizedName}-${version}.dist-info`;
const metadata = `Metadata-Version: 2.1
Name: ${packageName}
Version: ${version}
Summary: Test Python package
Home-page: https://example.com
Author: Test Author
Author-email: test@example.com
License: MIT
Platform: UNKNOWN
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.7
Description-Content-Type: text/markdown
# ${packageName}
Test package for SmartRegistry
`;
const wheelContent = `Wheel-Version: 1.0
Generator: test 1.0.0
Root-Is-Purelib: true
Tag: ${pyVersion}-none-any
`;
const moduleContent = `"""${packageName} module"""
__version__ = "${version}"
def hello():
return "Hello from ${packageName}!"
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: `${distInfoDir}/METADATA`,
content: Buffer.from(metadata, 'utf-8'),
},
{
archivePath: `${distInfoDir}/WHEEL`,
content: Buffer.from(wheelContent, 'utf-8'),
},
{
archivePath: `${distInfoDir}/RECORD`,
content: Buffer.from('', 'utf-8'),
},
{
archivePath: `${distInfoDir}/top_level.txt`,
content: Buffer.from(normalizedName, 'utf-8'),
},
{
archivePath: `${normalizedName}/__init__.py`,
content: Buffer.from(moduleContent, 'utf-8'),
},
];
return Buffer.from(await zipTools.createZip(entries));
}
/**
* Helper to create a test Python source distribution (sdist) using smartarchive
*/
export async function createPythonSdist(
packageName: string,
version: string
): Promise<Buffer> {
const tarTools = new smartarchive.TarTools();
const normalizedName = packageName.replace(/-/g, '_');
const dirPrefix = `${packageName}-${version}`;
const pkgInfo = `Metadata-Version: 2.1
Name: ${packageName}
Version: ${version}
Summary: Test Python package
Home-page: https://example.com
Author: Test Author
Author-email: test@example.com
License: MIT
`;
const setupPy = `from setuptools import setup, find_packages
setup(
name="${packageName}",
version="${version}",
packages=find_packages(),
python_requires=">=3.7",
)
`;
const moduleContent = `"""${packageName} module"""
__version__ = "${version}"
def hello():
return "Hello from ${packageName}!"
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: `${dirPrefix}/PKG-INFO`,
content: Buffer.from(pkgInfo, 'utf-8'),
},
{
archivePath: `${dirPrefix}/setup.py`,
content: Buffer.from(setupPy, 'utf-8'),
},
{
archivePath: `${dirPrefix}/${normalizedName}/__init__.py`,
content: Buffer.from(moduleContent, 'utf-8'),
},
];
return Buffer.from(await tarTools.packFilesToTarGz(entries));
}
/**
* Helper to calculate PyPI file hashes
*/
export function calculatePypiHashes(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
blake2b: crypto.createHash('blake2b512').update(data).digest('hex'),
};
}
/**
* Helper to create a test RubyGem file (minimal tar.gz structure) using smartarchive
*/
export async function createRubyGem(
gemName: string,
version: string,
platform: string = 'ruby'
): Promise<Buffer> {
const tarTools = new smartarchive.TarTools();
const gzipTools = new smartarchive.GzipTools();
const metadataYaml = `--- !ruby/object:Gem::Specification
name: ${gemName}
version: !ruby/object:Gem::Version
version: ${version}
platform: ${platform}
authors:
- Test Author
autorequire:
bindir: bin
cert_chain: []
date: ${new Date().toISOString().split('T')[0]}
dependencies: []
description: Test RubyGem
email: test@example.com
executables: []
extensions: []
extra_rdoc_files: []
files:
- lib/${gemName}.rb
homepage: https://example.com
licenses:
- MIT
metadata: {}
post_install_message:
rdoc_options: []
require_paths:
- lib
required_ruby_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '2.7'
required_rubygems_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '0'
requirements: []
rubygems_version: 3.0.0
signing_key:
specification_version: 4
summary: Test gem for SmartRegistry
test_files: []
`;
const metadataGz = Buffer.from(await gzipTools.compress(Buffer.from(metadataYaml, 'utf-8')));
const libContent = `# ${gemName}
module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')}
VERSION = "${version}"
def self.hello
"Hello from #{gemName}!"
end
end
`;
const dataEntries: smartarchive.IArchiveEntry[] = [
{
archivePath: `lib/${gemName}.rb`,
content: Buffer.from(libContent, 'utf-8'),
},
];
const dataTarGz = Buffer.from(await tarTools.packFilesToTarGz(dataEntries));
const gemEntries: smartarchive.IArchiveEntry[] = [
{
archivePath: 'metadata.gz',
content: metadataGz,
},
{
archivePath: 'data.tar.gz',
content: dataTarGz,
},
];
return Buffer.from(await tarTools.packFiles(gemEntries));
}
/**
* Helper to calculate RubyGems checksums
*/
export function calculateRubyGemsChecksums(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
};
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Generate a unique test run ID for avoiding conflicts between test runs.
*/
export function generateTestRunId(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 6);
return `${timestamp}${random}`;
}
+125
View File
@@ -0,0 +1,125 @@
import type { IAuthToken, TRegistryProtocol } from '../../ts/core/interfaces.core.js';
import type { IAuthProvider } from '../../ts/core/interfaces.auth.js';
import type {
IUpstreamProvider,
IUpstreamRegistryConfig,
IUpstreamResolutionContext,
IProtocolUpstreamConfig,
} from '../../ts/upstream/interfaces.upstream.js';
type TTestUpstreamRegistryConfig = Omit<Partial<IUpstreamRegistryConfig>, 'id' | 'url' | 'priority' | 'enabled'> &
Pick<IUpstreamRegistryConfig, 'id' | 'url' | 'priority' | 'enabled'>;
type TTestProtocolUpstreamConfig = Omit<IProtocolUpstreamConfig, 'upstreams'> & {
upstreams: TTestUpstreamRegistryConfig[];
};
function normalizeUpstreamRegistryConfig(
upstream: TTestUpstreamRegistryConfig
): IUpstreamRegistryConfig {
return {
...upstream,
name: upstream.name ?? upstream.id,
auth: upstream.auth ?? { type: 'none' },
};
}
function normalizeProtocolUpstreamConfig(
config: TTestProtocolUpstreamConfig | undefined
): IProtocolUpstreamConfig | null {
if (!config) {
return null;
}
return {
...config,
upstreams: config.upstreams.map(normalizeUpstreamRegistryConfig),
};
}
/**
* Create a mock upstream provider that tracks all calls for testing
*/
export function createTrackingUpstreamProvider(
baseConfig?: Partial<Record<TRegistryProtocol, TTestProtocolUpstreamConfig>>
): {
provider: IUpstreamProvider;
calls: IUpstreamResolutionContext[];
} {
const calls: IUpstreamResolutionContext[] = [];
const provider: IUpstreamProvider = {
async resolveUpstreamConfig(context: IUpstreamResolutionContext) {
calls.push({ ...context });
return normalizeProtocolUpstreamConfig(baseConfig?.[context.protocol]);
},
};
return { provider, calls };
}
/**
* Create a mock auth provider for testing pluggable authentication.
* Allows customizing behavior for different test scenarios.
*/
export function createMockAuthProvider(overrides?: Partial<IAuthProvider>): IAuthProvider {
const tokens = new Map<string, IAuthToken>();
return {
init: async () => {},
authenticate: async (credentials) => {
return credentials.username;
},
validateToken: async (token, protocol) => {
const stored = tokens.get(token);
if (stored && (!protocol || stored.type === protocol)) {
return stored;
}
if (token === 'valid-mock-token') {
return {
type: 'npm' as TRegistryProtocol,
userId: 'mock-user',
scopes: ['npm:*:*:*'],
};
}
return null;
},
createToken: async (userId, protocol, options) => {
const tokenId = `mock-${protocol}-${Date.now()}`;
const authToken: IAuthToken = {
type: protocol,
userId,
scopes: options?.scopes || [`${protocol}:*:*:*`],
readonly: options?.readonly,
expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn * 1000) : undefined,
};
tokens.set(tokenId, authToken);
return tokenId;
},
revokeToken: async (token) => {
tokens.delete(token);
},
authorize: async (token, resource, action) => {
if (!token) return false;
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
return false;
}
return true;
},
listUserTokens: async (userId) => {
const result: Array<{ key: string; readonly: boolean; created: string; protocol?: TRegistryProtocol }> = [];
for (const [key, token] of tokens.entries()) {
if (token.userId === userId) {
result.push({
key: `hash-${key.substring(0, 8)}`,
readonly: token.readonly || false,
created: new Date().toISOString(),
protocol: token.type,
});
}
}
return result;
},
...overrides,
};
}
+34 -869
View File
@@ -1,30 +1,34 @@
import * as qenv from '@push.rocks/qenv';
import * as crypto from 'crypto';
import * as smartarchive from '@push.rocks/smartarchive';
import * as smartbucket from '@push.rocks/smartbucket';
import { SmartRegistry } from '../../ts/classes.smartregistry.js';
import type { IRegistryConfig, IAuthToken, TRegistryProtocol } from '../../ts/core/interfaces.core.js';
import type { IAuthProvider, ITokenOptions } from '../../ts/core/interfaces.auth.js';
import type { IStorageHooks, IStorageHookContext, IBeforePutResult, IBeforeDeleteResult } from '../../ts/core/interfaces.storage.js';
import { StaticUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
import type { IUpstreamProvider, IUpstreamResolutionContext, IProtocolUpstreamConfig } from '../../ts/upstream/interfaces.upstream.js';
import type { IStorageHooks } from '../../ts/core/interfaces.storage.js';
import type { IUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
import { buildTestRegistryConfig, createDefaultTestUpstreamProvider } from './registryconfig.js';
import { generateTestRunId } from './ids.js';
export {
calculateDigest,
createTestManifest,
createTestPackument,
createTestPom,
createTestJar,
calculateMavenChecksums,
createComposerZip,
createPythonWheel,
createPythonSdist,
calculatePypiHashes,
createRubyGem,
calculateRubyGemsChecksums,
} from './fixtures.js';
export { createMockAuthProvider, createTrackingUpstreamProvider } from './providers.js';
export { buildTestRegistryConfig, createDefaultTestUpstreamProvider } from './registryconfig.js';
export { createTestStorageBackend } from './storagebackend.js';
export { generateTestRunId } from './ids.js';
export { createTestTokens } from './tokens.js';
export { createTrackingHooks, createQuotaHooks } from './storagehooks.js';
const testQenv = new qenv.Qenv('./', './.nogit');
/**
* Clean up S3 bucket contents for a fresh test run
* @param prefix Optional prefix to delete (e.g., 'cargo/', 'npm/', 'composer/')
*/
/**
* Generate a unique test run ID for avoiding conflicts between test runs
* Uses timestamp + random suffix for uniqueness
*/
export function generateTestRunId(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 6);
return `${timestamp}${random}`;
}
export async function cleanupS3Bucket(prefix?: string): Promise<void> {
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
@@ -40,19 +44,17 @@ export async function cleanupS3Bucket(prefix?: string): Promise<void> {
});
try {
const bucket = await s3.getBucket('test-registry');
const bucket = await s3.getBucketByName('test-registry');
if (bucket) {
if (prefix) {
// Delete only objects with the given prefix
const files = await bucket.fastList({ prefix });
for (const file of files) {
await bucket.fastRemove({ path: file.name });
for await (const path of bucket.listAllObjects(prefix)) {
await bucket.fastRemove({ path });
}
} else {
// Delete all objects in the bucket
const files = await bucket.fastList({});
for (const file of files) {
await bucket.fastRemove({ path: file.name });
for await (const path of bucket.listAllObjects()) {
await bucket.fastRemove({ path });
}
}
}
@@ -67,77 +69,9 @@ export async function cleanupS3Bucket(prefix?: string): Promise<void> {
*/
export async function createTestRegistry(options?: {
registryUrl?: string;
storageHooks?: IStorageHooks;
}): Promise<SmartRegistry> {
// Read S3 config from env.json
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
const config: IRegistryConfig = {
storage: {
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
region: 'us-east-1',
bucketName: 'test-registry',
},
auth: {
jwtSecret: 'test-secret-key',
tokenStore: 'memory',
npmTokens: {
enabled: true,
},
ociTokens: {
enabled: true,
realm: 'https://auth.example.com/token',
service: 'test-registry',
},
pypiTokens: {
enabled: true,
},
rubygemsTokens: {
enabled: true,
},
},
oci: {
enabled: true,
basePath: '/oci',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/oci` } : {}),
},
npm: {
enabled: true,
basePath: '/npm',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/npm` } : {}),
},
maven: {
enabled: true,
basePath: '/maven',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/maven` } : {}),
},
composer: {
enabled: true,
basePath: '/composer',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/composer` } : {}),
},
cargo: {
enabled: true,
basePath: '/cargo',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/cargo` } : {}),
},
pypi: {
enabled: true,
basePath: '/pypi',
...(options?.registryUrl ? { registryUrl: options.registryUrl } : {}),
},
rubygems: {
enabled: true,
basePath: '/rubygems',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/rubygems` } : {}),
},
};
const config = await buildTestRegistryConfig(options);
const registry = new SmartRegistry(config);
await registry.init();
@@ -151,781 +85,12 @@ export async function createTestRegistry(options?: {
export async function createTestRegistryWithUpstream(
upstreamProvider?: IUpstreamProvider
): Promise<SmartRegistry> {
// Read S3 config from env.json
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
// Default to StaticUpstreamProvider with npm.js configured
const defaultProvider = new StaticUpstreamProvider({
npm: {
enabled: true,
upstreams: [{ id: 'npmjs', url: 'https://registry.npmjs.org', priority: 1, enabled: true }],
},
oci: {
enabled: true,
upstreams: [{ id: 'dockerhub', url: 'https://registry-1.docker.io', priority: 1, enabled: true }],
},
const config = await buildTestRegistryConfig({
upstreamProvider: upstreamProvider || createDefaultTestUpstreamProvider(),
});
const config: IRegistryConfig = {
storage: {
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
region: 'us-east-1',
bucketName: 'test-registry',
},
auth: {
jwtSecret: 'test-secret-key',
tokenStore: 'memory',
npmTokens: { enabled: true },
ociTokens: {
enabled: true,
realm: 'https://auth.example.com/token',
service: 'test-registry',
},
pypiTokens: { enabled: true },
rubygemsTokens: { enabled: true },
},
upstreamProvider: upstreamProvider || defaultProvider,
oci: { enabled: true, basePath: '/oci' },
npm: { enabled: true, basePath: '/npm' },
maven: { enabled: true, basePath: '/maven' },
composer: { enabled: true, basePath: '/composer' },
cargo: { enabled: true, basePath: '/cargo' },
pypi: { enabled: true, basePath: '/pypi' },
rubygems: { enabled: true, basePath: '/rubygems' },
};
const registry = new SmartRegistry(config);
await registry.init();
return registry;
}
/**
* Create a mock upstream provider that tracks all calls for testing
*/
export function createTrackingUpstreamProvider(
baseConfig?: Partial<Record<TRegistryProtocol, IProtocolUpstreamConfig>>
): {
provider: IUpstreamProvider;
calls: IUpstreamResolutionContext[];
} {
const calls: IUpstreamResolutionContext[] = [];
const provider: IUpstreamProvider = {
async resolveUpstreamConfig(context: IUpstreamResolutionContext) {
calls.push({ ...context });
return baseConfig?.[context.protocol] ?? null;
},
};
return { provider, calls };
}
/**
* Helper to create test authentication tokens
*/
export async function createTestTokens(registry: SmartRegistry) {
const authManager = registry.getAuthManager();
// Authenticate and create tokens
const userId = await authManager.authenticate({
username: 'testuser',
password: 'testpass',
});
if (!userId) {
throw new Error('Failed to authenticate test user');
}
// Create NPM token
const npmToken = await authManager.createNpmToken(userId, false);
// Create OCI token with full access
const ociToken = await authManager.createOciToken(
userId,
['oci:repository:*:*'],
3600
);
// Create Maven token with full access
const mavenToken = await authManager.createMavenToken(userId, false);
// Create Composer token with full access
const composerToken = await authManager.createComposerToken(userId, false);
// Create Cargo token with full access
const cargoToken = await authManager.createCargoToken(userId, false);
// Create PyPI token with full access
const pypiToken = await authManager.createPypiToken(userId, false);
// Create RubyGems token with full access
const rubygemsToken = await authManager.createRubyGemsToken(userId, false);
return { npmToken, ociToken, mavenToken, composerToken, cargoToken, pypiToken, rubygemsToken, userId };
}
/**
* Helper to calculate SHA-256 digest in OCI format
*/
export function calculateDigest(data: Buffer): string {
const hash = crypto.createHash('sha256').update(data).digest('hex');
return `sha256:${hash}`;
}
/**
* Helper to create a minimal valid OCI manifest
*/
export function createTestManifest(configDigest: string, layerDigest: string) {
return {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
config: {
mediaType: 'application/vnd.oci.image.config.v1+json',
size: 123,
digest: configDigest,
},
layers: [
{
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
size: 456,
digest: layerDigest,
},
],
};
}
/**
* Helper to create a minimal valid NPM packument
*/
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
return {
name: packageName,
versions: {
[version]: {
name: packageName,
version: version,
description: 'Test package',
main: 'index.js',
scripts: {},
dist: {
shasum: shasum,
integrity: integrity,
tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`,
},
},
},
'dist-tags': {
latest: version,
},
_attachments: {
[`${packageName}-${version}.tgz`]: {
content_type: 'application/octet-stream',
data: tarballData.toString('base64'),
length: tarballData.length,
},
},
};
}
/**
* Helper to create a minimal valid Maven POM file
*/
export function createTestPom(
groupId: string,
artifactId: string,
version: string,
packaging: string = 'jar'
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>${groupId}</groupId>
<artifactId>${artifactId}</artifactId>
<version>${version}</version>
<packaging>${packaging}</packaging>
<name>${artifactId}</name>
<description>Test Maven artifact</description>
</project>`;
}
/**
* Helper to create a test JAR file (minimal ZIP with manifest)
*/
export function createTestJar(): Buffer {
// Create a simple JAR structure (just a manifest)
// In practice, this is a ZIP file with at least META-INF/MANIFEST.MF
const manifestContent = `Manifest-Version: 1.0
Created-By: SmartRegistry Test
`;
// For testing, we'll just create a buffer with dummy content
// Real JAR would be a proper ZIP archive
return Buffer.from(manifestContent, 'utf-8');
}
/**
* Helper to calculate Maven checksums
*/
export function calculateMavenChecksums(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha1: crypto.createHash('sha1').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
sha512: crypto.createHash('sha512').update(data).digest('hex'),
};
}
/**
* Helper to create a Composer package ZIP using smartarchive
*/
export async function createComposerZip(
vendorPackage: string,
version: string,
options?: {
description?: string;
license?: string[];
authors?: Array<{ name: string; email?: string }>;
}
): Promise<Buffer> {
const zipTools = new smartarchive.ZipTools();
const composerJson = {
name: vendorPackage,
version: version,
type: 'library',
description: options?.description || 'Test Composer package',
license: options?.license || ['MIT'],
authors: options?.authors || [{ name: 'Test Author', email: 'test@example.com' }],
require: {
php: '>=7.4',
},
autoload: {
'psr-4': {
'Vendor\\TestPackage\\': 'src/',
},
},
};
// Add a test PHP file
const [vendor, pkg] = vendorPackage.split('/');
const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`;
const testPhpContent = `<?php
namespace ${namespace};
class TestClass
{
public function greet(): string
{
return "Hello from ${vendorPackage}!";
}
}
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: 'composer.json',
content: Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'),
},
{
archivePath: 'src/TestClass.php',
content: Buffer.from(testPhpContent, 'utf-8'),
},
{
archivePath: 'README.md',
content: Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'),
},
];
return Buffer.from(await zipTools.createZip(entries));
}
/**
* Helper to create a test Python wheel file (minimal ZIP structure) using smartarchive
*/
export async function createPythonWheel(
packageName: string,
version: string,
pyVersion: string = 'py3'
): Promise<Buffer> {
const zipTools = new smartarchive.ZipTools();
const normalizedName = packageName.replace(/-/g, '_');
const distInfoDir = `${normalizedName}-${version}.dist-info`;
// Create METADATA file
const metadata = `Metadata-Version: 2.1
Name: ${packageName}
Version: ${version}
Summary: Test Python package
Home-page: https://example.com
Author: Test Author
Author-email: test@example.com
License: MIT
Platform: UNKNOWN
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.7
Description-Content-Type: text/markdown
# ${packageName}
Test package for SmartRegistry
`;
// Create WHEEL file
const wheelContent = `Wheel-Version: 1.0
Generator: test 1.0.0
Root-Is-Purelib: true
Tag: ${pyVersion}-none-any
`;
// Create a simple Python module
const moduleContent = `"""${packageName} module"""
__version__ = "${version}"
def hello():
return "Hello from ${packageName}!"
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: `${distInfoDir}/METADATA`,
content: Buffer.from(metadata, 'utf-8'),
},
{
archivePath: `${distInfoDir}/WHEEL`,
content: Buffer.from(wheelContent, 'utf-8'),
},
{
archivePath: `${distInfoDir}/RECORD`,
content: Buffer.from('', 'utf-8'),
},
{
archivePath: `${distInfoDir}/top_level.txt`,
content: Buffer.from(normalizedName, 'utf-8'),
},
{
archivePath: `${normalizedName}/__init__.py`,
content: Buffer.from(moduleContent, 'utf-8'),
},
];
return Buffer.from(await zipTools.createZip(entries));
}
/**
* Helper to create a test Python source distribution (sdist) using smartarchive
*/
export async function createPythonSdist(
packageName: string,
version: string
): Promise<Buffer> {
const tarTools = new smartarchive.TarTools();
const normalizedName = packageName.replace(/-/g, '_');
const dirPrefix = `${packageName}-${version}`;
// PKG-INFO
const pkgInfo = `Metadata-Version: 2.1
Name: ${packageName}
Version: ${version}
Summary: Test Python package
Home-page: https://example.com
Author: Test Author
Author-email: test@example.com
License: MIT
`;
// setup.py
const setupPy = `from setuptools import setup, find_packages
setup(
name="${packageName}",
version="${version}",
packages=find_packages(),
python_requires=">=3.7",
)
`;
// Module file
const moduleContent = `"""${packageName} module"""
__version__ = "${version}"
def hello():
return "Hello from ${packageName}!"
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: `${dirPrefix}/PKG-INFO`,
content: Buffer.from(pkgInfo, 'utf-8'),
},
{
archivePath: `${dirPrefix}/setup.py`,
content: Buffer.from(setupPy, 'utf-8'),
},
{
archivePath: `${dirPrefix}/${normalizedName}/__init__.py`,
content: Buffer.from(moduleContent, 'utf-8'),
},
];
return Buffer.from(await tarTools.packFilesToTarGz(entries));
}
/**
* Helper to calculate PyPI file hashes
*/
export function calculatePypiHashes(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
blake2b: crypto.createHash('blake2b512').update(data).digest('hex'),
};
}
/**
* Helper to create a test RubyGem file (minimal tar.gz structure) using smartarchive
*/
export async function createRubyGem(
gemName: string,
version: string,
platform: string = 'ruby'
): Promise<Buffer> {
const tarTools = new smartarchive.TarTools();
const gzipTools = new smartarchive.GzipTools();
// Create metadata.gz (simplified)
const metadataYaml = `--- !ruby/object:Gem::Specification
name: ${gemName}
version: !ruby/object:Gem::Version
version: ${version}
platform: ${platform}
authors:
- Test Author
autorequire:
bindir: bin
cert_chain: []
date: ${new Date().toISOString().split('T')[0]}
dependencies: []
description: Test RubyGem
email: test@example.com
executables: []
extensions: []
extra_rdoc_files: []
files:
- lib/${gemName}.rb
homepage: https://example.com
licenses:
- MIT
metadata: {}
post_install_message:
rdoc_options: []
require_paths:
- lib
required_ruby_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '2.7'
required_rubygems_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '0'
requirements: []
rubygems_version: 3.0.0
signing_key:
specification_version: 4
summary: Test gem for SmartRegistry
test_files: []
`;
const metadataGz = Buffer.from(await gzipTools.compress(Buffer.from(metadataYaml, 'utf-8')));
// Create data.tar.gz content
const libContent = `# ${gemName}
module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')}
VERSION = "${version}"
def self.hello
"Hello from #{gemName}!"
end
end
`;
const dataEntries: smartarchive.IArchiveEntry[] = [
{
archivePath: `lib/${gemName}.rb`,
content: Buffer.from(libContent, 'utf-8'),
},
];
const dataTarGz = Buffer.from(await tarTools.packFilesToTarGz(dataEntries));
// Create the outer gem (tar.gz containing metadata.gz and data.tar.gz)
const gemEntries: smartarchive.IArchiveEntry[] = [
{
archivePath: 'metadata.gz',
content: metadataGz,
},
{
archivePath: 'data.tar.gz',
content: dataTarGz,
},
];
// RubyGems .gem files are plain tar archives (NOT gzipped), containing metadata.gz and data.tar.gz
return Buffer.from(await tarTools.packFiles(gemEntries));
}
/**
* Helper to calculate RubyGems checksums
*/
export function calculateRubyGemsChecksums(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
};
}
// ============================================================================
// Enterprise Extensibility Test Helpers
// ============================================================================
/**
* Create a mock auth provider for testing pluggable authentication.
* Allows customizing behavior for different test scenarios.
*/
export function createMockAuthProvider(overrides?: Partial<IAuthProvider>): IAuthProvider {
const tokens = new Map<string, IAuthToken>();
return {
init: async () => {},
authenticate: async (credentials) => {
// Default: always authenticate successfully
return credentials.username;
},
validateToken: async (token, protocol) => {
const stored = tokens.get(token);
if (stored && (!protocol || stored.type === protocol)) {
return stored;
}
// Mock token for tests
if (token === 'valid-mock-token') {
return {
type: 'npm' as TRegistryProtocol,
userId: 'mock-user',
scopes: ['npm:*:*:*'],
};
}
return null;
},
createToken: async (userId, protocol, options) => {
const tokenId = `mock-${protocol}-${Date.now()}`;
const authToken: IAuthToken = {
type: protocol,
userId,
scopes: options?.scopes || [`${protocol}:*:*:*`],
readonly: options?.readonly,
expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn * 1000) : undefined,
};
tokens.set(tokenId, authToken);
return tokenId;
},
revokeToken: async (token) => {
tokens.delete(token);
},
authorize: async (token, resource, action) => {
if (!token) return false;
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
return false;
}
return true;
},
listUserTokens: async (userId) => {
const result: Array<{ key: string; readonly: boolean; created: string; protocol?: TRegistryProtocol }> = [];
for (const [key, token] of tokens.entries()) {
if (token.userId === userId) {
result.push({
key: `hash-${key.substring(0, 8)}`,
readonly: token.readonly || false,
created: new Date().toISOString(),
protocol: token.type,
});
}
}
return result;
},
...overrides,
};
}
/**
* Create test storage hooks that track all calls.
* Useful for verifying hook invocation order and parameters.
*/
export function createTrackingHooks(options?: {
beforePutAllowed?: boolean;
beforeDeleteAllowed?: boolean;
throwOnAfterPut?: boolean;
throwOnAfterGet?: boolean;
}): {
hooks: IStorageHooks;
calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }>;
} {
const calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }> = [];
return {
calls,
hooks: {
beforePut: async (ctx) => {
calls.push({ method: 'beforePut', context: ctx, timestamp: Date.now() });
return {
allowed: options?.beforePutAllowed !== false,
reason: options?.beforePutAllowed === false ? 'Blocked by test' : undefined,
};
},
afterPut: async (ctx) => {
calls.push({ method: 'afterPut', context: ctx, timestamp: Date.now() });
if (options?.throwOnAfterPut) {
throw new Error('Test error in afterPut');
}
},
beforeDelete: async (ctx) => {
calls.push({ method: 'beforeDelete', context: ctx, timestamp: Date.now() });
return {
allowed: options?.beforeDeleteAllowed !== false,
reason: options?.beforeDeleteAllowed === false ? 'Blocked by test' : undefined,
};
},
afterDelete: async (ctx) => {
calls.push({ method: 'afterDelete', context: ctx, timestamp: Date.now() });
},
afterGet: async (ctx) => {
calls.push({ method: 'afterGet', context: ctx, timestamp: Date.now() });
if (options?.throwOnAfterGet) {
throw new Error('Test error in afterGet');
}
},
},
};
}
/**
* Create a blocking storage hooks implementation for quota testing.
*/
export function createQuotaHooks(maxSizeBytes: number): {
hooks: IStorageHooks;
currentUsage: { bytes: number };
} {
const currentUsage = { bytes: 0 };
return {
currentUsage,
hooks: {
beforePut: async (ctx) => {
const size = ctx.metadata?.size || 0;
if (currentUsage.bytes + size > maxSizeBytes) {
return { allowed: false, reason: `Quota exceeded: ${currentUsage.bytes + size} > ${maxSizeBytes}` };
}
return { allowed: true };
},
afterPut: async (ctx) => {
currentUsage.bytes += ctx.metadata?.size || 0;
},
afterDelete: async (ctx) => {
currentUsage.bytes -= ctx.metadata?.size || 0;
if (currentUsage.bytes < 0) currentUsage.bytes = 0;
},
},
};
}
/**
* Create a SmartBucket storage backend for upstream cache testing.
*/
export async function createTestStorageBackend(): Promise<{
storage: {
getObject: (key: string) => Promise<Buffer | null>;
putObject: (key: string, data: Buffer) => Promise<void>;
deleteObject: (key: string) => Promise<void>;
listObjects: (prefix: string) => Promise<string[]>;
};
bucket: smartbucket.Bucket;
cleanup: () => Promise<void>;
}> {
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
const s3 = new smartbucket.SmartBucket({
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
});
const testRunId = generateTestRunId();
const bucketName = 'test-cache-' + testRunId.substring(0, 8);
const bucket = await s3.createBucket(bucketName);
const storage = {
getObject: async (key: string): Promise<Buffer | null> => {
try {
const file = await bucket.fastGet({ path: key });
if (!file) return null;
const stream = await file.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
} catch {
return null;
}
},
putObject: async (key: string, data: Buffer): Promise<void> => {
await bucket.fastPut({ path: key, contents: data, overwrite: true });
},
deleteObject: async (key: string): Promise<void> => {
await bucket.fastRemove({ path: key });
},
listObjects: async (prefix: string): Promise<string[]> => {
const files = await bucket.fastList({ prefix });
return files.map(f => f.name);
},
};
const cleanup = async () => {
try {
const files = await bucket.fastList({});
for (const file of files) {
await bucket.fastRemove({ path: file.name });
}
await s3.removeBucket(bucketName);
} catch {
// Ignore cleanup errors
}
};
return { storage, bucket, cleanup };
}
+122
View File
@@ -0,0 +1,122 @@
import * as qenv from '@push.rocks/qenv';
import type { IRegistryConfig } from '../../ts/core/interfaces.core.js';
import type { IStorageHooks } from '../../ts/core/interfaces.storage.js';
import { StaticUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
import type { IUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
const testQenv = new qenv.Qenv('./', './.nogit');
async function getTestStorageConfig(): Promise<IRegistryConfig['storage']> {
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
return {
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
region: 'us-east-1',
bucketName: 'test-registry',
};
}
function getTestAuthConfig(): IRegistryConfig['auth'] {
return {
jwtSecret: 'test-secret-key',
tokenStore: 'memory',
npmTokens: {
enabled: true,
},
ociTokens: {
enabled: true,
realm: 'https://auth.example.com/token',
service: 'test-registry',
},
pypiTokens: {
enabled: true,
},
rubygemsTokens: {
enabled: true,
},
};
}
export function createDefaultTestUpstreamProvider(): IUpstreamProvider {
return new StaticUpstreamProvider({
npm: {
enabled: true,
upstreams: [{
id: 'npmjs',
name: 'npmjs',
url: 'https://registry.npmjs.org',
priority: 1,
enabled: true,
auth: { type: 'none' },
}],
},
oci: {
enabled: true,
upstreams: [{
id: 'dockerhub',
name: 'dockerhub',
url: 'https://registry-1.docker.io',
priority: 1,
enabled: true,
auth: { type: 'none' },
}],
},
});
}
export async function buildTestRegistryConfig(options?: {
registryUrl?: string;
storageHooks?: IStorageHooks;
upstreamProvider?: IUpstreamProvider;
}): Promise<IRegistryConfig> {
const config: IRegistryConfig = {
storage: await getTestStorageConfig(),
auth: getTestAuthConfig(),
...(options?.storageHooks ? { storageHooks: options.storageHooks } : {}),
...(options?.upstreamProvider ? { upstreamProvider: options.upstreamProvider } : {}),
oci: {
enabled: true,
basePath: '/oci',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/oci` } : {}),
},
npm: {
enabled: true,
basePath: '/npm',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/npm` } : {}),
},
maven: {
enabled: true,
basePath: '/maven',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/maven` } : {}),
},
composer: {
enabled: true,
basePath: '/composer',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/composer` } : {}),
},
cargo: {
enabled: true,
basePath: '/cargo',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/cargo` } : {}),
},
pypi: {
enabled: true,
basePath: '/pypi',
...(options?.registryUrl ? { registryUrl: options.registryUrl } : {}),
},
rubygems: {
enabled: true,
basePath: '/rubygems',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/rubygems` } : {}),
},
};
return config;
}
+72
View File
@@ -0,0 +1,72 @@
import * as qenv from '@push.rocks/qenv';
import * as smartbucket from '@push.rocks/smartbucket';
import { generateTestRunId } from './ids.js';
const testQenv = new qenv.Qenv('./', './.nogit');
/**
* Create a SmartBucket storage backend for upstream cache testing.
*/
export async function createTestStorageBackend(): Promise<{
storage: {
getObject: (key: string) => Promise<Buffer | null>;
putObject: (key: string, data: Buffer) => Promise<void>;
deleteObject: (key: string) => Promise<void>;
listObjects: (prefix: string) => Promise<string[]>;
};
bucket: smartbucket.Bucket;
cleanup: () => Promise<void>;
}> {
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
const s3 = new smartbucket.SmartBucket({
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
});
const testRunId = generateTestRunId();
const bucketName = 'test-cache-' + testRunId.substring(0, 8);
const bucket = await s3.createBucket(bucketName);
const storage = {
getObject: async (key: string): Promise<Buffer | null> => {
try {
return await bucket.fastGet({ path: key });
} catch {
return null;
}
},
putObject: async (key: string, data: Buffer): Promise<void> => {
await bucket.fastPut({ path: key, contents: data, overwrite: true });
},
deleteObject: async (key: string): Promise<void> => {
await bucket.fastRemove({ path: key });
},
listObjects: async (prefix: string): Promise<string[]> => {
const paths: string[] = [];
for await (const path of bucket.listAllObjects(prefix)) {
paths.push(path);
}
return paths;
},
};
const cleanup = async () => {
try {
for await (const path of bucket.listAllObjects()) {
await bucket.fastRemove({ path });
}
await s3.removeBucket(bucketName);
} catch {
// Ignore cleanup errors
}
};
return { storage, bucket, cleanup };
}
+82
View File
@@ -0,0 +1,82 @@
import type { IStorageHooks, IStorageHookContext } from '../../ts/core/interfaces.storage.js';
/**
* Create test storage hooks that track all calls.
* Useful for verifying hook invocation order and parameters.
*/
export function createTrackingHooks(options?: {
beforePutAllowed?: boolean;
beforeDeleteAllowed?: boolean;
throwOnAfterPut?: boolean;
throwOnAfterGet?: boolean;
}): {
hooks: IStorageHooks;
calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }>;
} {
const calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }> = [];
return {
calls,
hooks: {
beforePut: async (ctx) => {
calls.push({ method: 'beforePut', context: ctx, timestamp: Date.now() });
return {
allowed: options?.beforePutAllowed !== false,
reason: options?.beforePutAllowed === false ? 'Blocked by test' : undefined,
};
},
afterPut: async (ctx) => {
calls.push({ method: 'afterPut', context: ctx, timestamp: Date.now() });
if (options?.throwOnAfterPut) {
throw new Error('Test error in afterPut');
}
},
beforeDelete: async (ctx) => {
calls.push({ method: 'beforeDelete', context: ctx, timestamp: Date.now() });
return {
allowed: options?.beforeDeleteAllowed !== false,
reason: options?.beforeDeleteAllowed === false ? 'Blocked by test' : undefined,
};
},
afterDelete: async (ctx) => {
calls.push({ method: 'afterDelete', context: ctx, timestamp: Date.now() });
},
afterGet: async (ctx) => {
calls.push({ method: 'afterGet', context: ctx, timestamp: Date.now() });
if (options?.throwOnAfterGet) {
throw new Error('Test error in afterGet');
}
},
},
};
}
/**
* Create a blocking storage hooks implementation for quota testing.
*/
export function createQuotaHooks(maxSizeBytes: number): {
hooks: IStorageHooks;
currentUsage: { bytes: number };
} {
const currentUsage = { bytes: 0 };
return {
currentUsage,
hooks: {
beforePut: async (ctx) => {
const size = ctx.metadata?.size || 0;
if (currentUsage.bytes + size > maxSizeBytes) {
return { allowed: false, reason: `Quota exceeded: ${currentUsage.bytes + size} > ${maxSizeBytes}` };
}
return { allowed: true };
},
afterPut: async (ctx) => {
currentUsage.bytes += ctx.metadata?.size || 0;
},
afterDelete: async (ctx) => {
currentUsage.bytes -= ctx.metadata?.size || 0;
if (currentUsage.bytes < 0) currentUsage.bytes = 0;
},
},
};
}
+27
View File
@@ -0,0 +1,27 @@
import type { SmartRegistry } from '../../ts/classes.smartregistry.js';
/**
* Helper to create test authentication tokens.
*/
export async function createTestTokens(registry: SmartRegistry) {
const authManager = registry.getAuthManager();
const userId = await authManager.authenticate({
username: 'testuser',
password: 'testpass',
});
if (!userId) {
throw new Error('Failed to authenticate test user');
}
const npmToken = await authManager.createNpmToken(userId, false);
const ociToken = await authManager.createOciToken(userId, ['oci:repository:*:*'], 3600);
const mavenToken = await authManager.createMavenToken(userId, false);
const composerToken = await authManager.createComposerToken(userId, false);
const cargoToken = await authManager.createCargoToken(userId, false);
const pypiToken = await authManager.createPypiToken(userId, false);
const rubygemsToken = await authManager.createRubyGemsToken(userId, false);
return { npmToken, ociToken, mavenToken, composerToken, cargoToken, pypiToken, rubygemsToken, userId };
}
+45 -1
View File
@@ -1,7 +1,7 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartRegistry } from '../ts/index.js';
import { streamToBuffer, streamToJson } from '../ts/core/helpers.stream.js';
import { createTestRegistry, createTestTokens, createTestPackument } from './helpers/registry.js';
import { createTestRegistry, createTestTokens, createTestPackument, generateTestRunId } from './helpers/registry.js';
let registry: SmartRegistry;
let npmToken: string;
@@ -137,6 +137,50 @@ tap.test('NPM: should publish a new version of the package', async () => {
expect(getBody.versions).toHaveProperty(newVersion);
});
tap.test('NPM: should support unencoded scoped package publish and metadata routes', async () => {
const scopedPackageName = `@scope/test-package-${generateTestRunId()}`;
const scopedVersion = '2.0.0';
const scopedTarballData = Buffer.from('scoped tarball content', 'utf-8');
const packument = createTestPackument(scopedPackageName, scopedVersion, scopedTarballData);
const publishResponse = await registry.handleRequest({
method: 'PUT',
path: `/npm/${scopedPackageName}`,
headers: {
Authorization: `Bearer ${npmToken}`,
'Content-Type': 'application/json',
},
query: {},
body: packument,
});
expect(publishResponse.status).toEqual(201);
const metadataResponse = await registry.handleRequest({
method: 'GET',
path: `/npm/${scopedPackageName}`,
headers: {},
query: {},
});
expect(metadataResponse.status).toEqual(200);
const metadataBody = await streamToJson(metadataResponse.body);
expect(metadataBody.name).toEqual(scopedPackageName);
expect(metadataBody.versions).toHaveProperty(scopedVersion);
const versionResponse = await registry.handleRequest({
method: 'GET',
path: `/npm/${scopedPackageName}/${scopedVersion}`,
headers: {},
query: {},
});
expect(versionResponse.status).toEqual(200);
const versionBody = await streamToJson(versionResponse.body);
expect(versionBody.name).toEqual(scopedPackageName);
expect(versionBody.version).toEqual(scopedVersion);
});
tap.test('NPM: should get dist-tags (GET /-/package/{pkg}/dist-tags)', async () => {
const response = await registry.handleRequest({
method: 'GET',
+142 -1
View File
@@ -3,7 +3,14 @@ import * as qenv from '@push.rocks/qenv';
import { RegistryStorage } from '../ts/core/classes.registrystorage.js';
import type { IStorageConfig } from '../ts/core/interfaces.core.js';
import type { IStorageHooks, IStorageHookContext } from '../ts/core/interfaces.storage.js';
import { createTrackingHooks, createQuotaHooks, generateTestRunId } from './helpers/registry.js';
import {
createQuotaHooks,
createTestPackument,
createTestRegistry,
createTestTokens,
createTrackingHooks,
generateTestRunId,
} from './helpers/registry.js';
const testQenv = new qenv.Qenv('./', './.nogit');
@@ -344,6 +351,140 @@ tap.test('withContext: should clear context even on error', async () => {
await errorStorage.putObject('test/after-error.txt', Buffer.from('ok'));
});
tap.test('withContext: should isolate concurrent async operations', async () => {
const tracker = createTrackingHooks();
const concurrentStorage = new RegistryStorage(storageConfig, tracker.hooks);
await concurrentStorage.init();
const bucket = (concurrentStorage as any).bucket;
const originalFastPut = bucket.fastPut.bind(bucket);
const pendingWrites: Array<() => void> = [];
let startedWrites = 0;
let waitingWrites = 0;
let startedResolve: () => void;
let waitingResolve: () => void;
const bothWritesStarted = new Promise<void>((resolve) => {
startedResolve = resolve;
});
const bothWritesWaiting = new Promise<void>((resolve) => {
waitingResolve = resolve;
});
bucket.fastPut = async (options: any) => {
startedWrites += 1;
if (startedWrites === 2) {
startedResolve();
}
await bothWritesStarted;
await new Promise<void>((resolve) => {
pendingWrites.push(resolve);
waitingWrites += 1;
if (waitingWrites === 2) {
waitingResolve();
}
});
return originalFastPut(options);
};
try {
const opA = concurrentStorage.withContext(
{
protocol: 'npm',
actor: { userId: 'user-a' },
metadata: { packageName: 'package-a' },
},
async () => {
await concurrentStorage.putObject('test/concurrent-a.txt', Buffer.from('a'));
}
);
const opB = concurrentStorage.withContext(
{
protocol: 'npm',
actor: { userId: 'user-b' },
metadata: { packageName: 'package-b' },
},
async () => {
await concurrentStorage.putObject('test/concurrent-b.txt', Buffer.from('b'));
}
);
await bothWritesWaiting;
pendingWrites[0]!();
pendingWrites[1]!();
await Promise.all([opA, opB]);
await new Promise(resolve => setTimeout(resolve, 100));
} finally {
bucket.fastPut = originalFastPut;
}
const afterPutCalls = tracker.calls.filter(
(call) => call.method === 'afterPut' && call.context.key.startsWith('test/concurrent-')
);
expect(afterPutCalls.length).toEqual(2);
const callByKey = new Map(afterPutCalls.map((call) => [call.context.key, call]));
expect(callByKey.get('test/concurrent-a.txt')?.context.actor?.userId).toEqual('user-a');
expect(callByKey.get('test/concurrent-a.txt')?.context.metadata?.packageName).toEqual('package-a');
expect(callByKey.get('test/concurrent-b.txt')?.context.actor?.userId).toEqual('user-b');
expect(callByKey.get('test/concurrent-b.txt')?.context.metadata?.packageName).toEqual('package-b');
});
tap.test('request hooks: should receive context during real npm publish requests', async () => {
const tracker = createTrackingHooks();
const registry = await createTestRegistry({ storageHooks: tracker.hooks });
try {
const tokens = await createTestTokens(registry);
const packageName = `hooked-package-${generateTestRunId()}`;
const version = '1.0.0';
const tarball = Buffer.from('hooked tarball data', 'utf-8');
const packument = createTestPackument(packageName, version, tarball);
const response = await registry.handleRequest({
method: 'PUT',
path: `/npm/${packageName}`,
headers: {
Authorization: `Bearer ${tokens.npmToken}`,
'Content-Type': 'application/json',
},
query: {},
body: packument,
});
expect(response.status).toEqual(201);
await new Promise(resolve => setTimeout(resolve, 100));
const npmWrites = tracker.calls.filter(
(call) => call.method === 'beforePut' && call.context.metadata?.packageName === packageName
);
expect(npmWrites.length).toBeGreaterThanOrEqual(2);
const packumentWrite = npmWrites.find(
(call) => call.context.key === `npm/packages/${packageName}/index.json`
);
expect(packumentWrite).toBeTruthy();
expect(packumentWrite!.context.protocol).toEqual('npm');
expect(packumentWrite!.context.actor?.userId).toEqual(tokens.userId);
expect(packumentWrite!.context.metadata?.packageName).toEqual(packageName);
const tarballWrite = npmWrites.find(
(call) => call.context.key.endsWith(`-${version}.tgz`)
);
expect(tarballWrite).toBeTruthy();
expect(tarballWrite!.context.metadata?.packageName).toEqual(packageName);
expect(tarballWrite!.context.metadata?.version).toEqual(version);
} finally {
registry.destroy();
}
});
// ============================================================================
// Graceful Degradation Tests
// ============================================================================
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartregistry',
version: '2.8.2',
version: '2.9.0',
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
}
+15 -30
View File
@@ -43,18 +43,7 @@ export class CargoRegistry extends BaseRegistry {
this.registryUrl = registryUrl;
this.upstreamProvider = upstreamProvider || null;
// Initialize logger
this.logger = new Smartlog({
logContext: {
company: 'push.rocks',
companyunit: 'smartregistry',
containerName: 'cargo-registry',
environment: (process.env.NODE_ENV as any) || 'development',
runtime: 'node',
zone: 'cargo'
}
});
this.logger.enableConsole();
this.logger = this.createProtocolLogger('cargo-registry', 'cargo');
}
/**
@@ -110,16 +99,10 @@ export class CargoRegistry extends BaseRegistry {
const path = context.path.replace(this.basePath, '');
// Extract token (Cargo uses Authorization header WITHOUT "Bearer" prefix)
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const authHeader = this.getAuthorizationHeader(context);
const token = authHeader ? await this.authManager.validateToken(authHeader, 'cargo') : null;
// Build actor from context and validated token
const actor: IRequestActor = {
...context.actor,
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
};
const actor: IRequestActor = this.buildRequestActor(context, token);
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
method: context.method,
@@ -127,18 +110,20 @@ export class CargoRegistry extends BaseRegistry {
hasAuth: !!token
});
// Config endpoint (required for sparse protocol)
if (path === '/config.json') {
return this.handleConfigJson();
}
return this.storage.withContext({ protocol: 'cargo', actor }, async () => {
// Config endpoint (required for sparse protocol)
if (path === '/config.json') {
return this.handleConfigJson();
}
// API endpoints
if (path.startsWith('/api/v1/')) {
return this.handleApiRequest(path, context, token, actor);
}
// API endpoints
if (path.startsWith('/api/v1/')) {
return this.handleApiRequest(path, context, token, actor);
}
// Index files (sparse protocol)
return this.handleIndexRequest(path, actor);
// Index files (sparse protocol)
return this.handleIndexRequest(path, actor);
});
}
/**
+154 -162
View File
@@ -1,7 +1,13 @@
import { RegistryStorage } from './core/classes.registrystorage.js';
import { AuthManager } from './core/classes.authmanager.js';
import { BaseRegistry } from './core/classes.baseregistry.js';
import type { IRegistryConfig, IRequestContext, IResponse } from './core/interfaces.core.js';
import type {
IProtocolConfig,
IRegistryConfig,
IRequestContext,
IResponse,
TRegistryProtocol,
} from './core/interfaces.core.js';
import { toReadableStream } from './core/helpers.stream.js';
import { OciRegistry } from './oci/classes.ociregistry.js';
import { NpmRegistry } from './npm/classes.npmregistry.js';
@@ -11,6 +17,129 @@ import { ComposerRegistry } from './composer/classes.composerregistry.js';
import { PypiRegistry } from './pypi/classes.pypiregistry.js';
import { RubyGemsRegistry } from './rubygems/classes.rubygemsregistry.js';
type TRegistryDescriptor = {
protocol: TRegistryProtocol;
getConfig: (config: IRegistryConfig) => IProtocolConfig | undefined;
matchesPath: (config: IRegistryConfig, path: string) => boolean;
create: (args: {
storage: RegistryStorage;
authManager: AuthManager;
config: IRegistryConfig;
protocolConfig: IProtocolConfig;
}) => BaseRegistry;
};
const registryDescriptors: TRegistryDescriptor[] = [
{
protocol: 'oci',
getConfig: (config) => config.oci,
matchesPath: (config, path) => path.startsWith(config.oci?.basePath ?? '/oci'),
create: ({ storage, authManager, config, protocolConfig }) => {
const ociTokens = config.auth.ociTokens?.enabled ? {
realm: config.auth.ociTokens.realm,
service: config.auth.ociTokens.service,
} : undefined;
return new OciRegistry(
storage,
authManager,
protocolConfig.basePath ?? '/oci',
ociTokens,
config.upstreamProvider
);
},
},
{
protocol: 'npm',
getConfig: (config) => config.npm,
matchesPath: (config, path) => path.startsWith(config.npm?.basePath ?? '/npm'),
create: ({ storage, authManager, config, protocolConfig }) => {
const basePath = protocolConfig.basePath ?? '/npm';
return new NpmRegistry(
storage,
authManager,
basePath,
protocolConfig.registryUrl ?? `http://localhost:5000${basePath}`,
config.upstreamProvider
);
},
},
{
protocol: 'maven',
getConfig: (config) => config.maven,
matchesPath: (config, path) => path.startsWith(config.maven?.basePath ?? '/maven'),
create: ({ storage, authManager, config, protocolConfig }) => {
const basePath = protocolConfig.basePath ?? '/maven';
return new MavenRegistry(
storage,
authManager,
basePath,
protocolConfig.registryUrl ?? `http://localhost:5000${basePath}`,
config.upstreamProvider
);
},
},
{
protocol: 'cargo',
getConfig: (config) => config.cargo,
matchesPath: (config, path) => path.startsWith(config.cargo?.basePath ?? '/cargo'),
create: ({ storage, authManager, config, protocolConfig }) => {
const basePath = protocolConfig.basePath ?? '/cargo';
return new CargoRegistry(
storage,
authManager,
basePath,
protocolConfig.registryUrl ?? `http://localhost:5000${basePath}`,
config.upstreamProvider
);
},
},
{
protocol: 'composer',
getConfig: (config) => config.composer,
matchesPath: (config, path) => path.startsWith(config.composer?.basePath ?? '/composer'),
create: ({ storage, authManager, config, protocolConfig }) => {
const basePath = protocolConfig.basePath ?? '/composer';
return new ComposerRegistry(
storage,
authManager,
basePath,
protocolConfig.registryUrl ?? `http://localhost:5000${basePath}`,
config.upstreamProvider
);
},
},
{
protocol: 'pypi',
getConfig: (config) => config.pypi,
matchesPath: (config, path) => {
const basePath = config.pypi?.basePath ?? '/pypi';
return path.startsWith(basePath) || path.startsWith('/simple');
},
create: ({ storage, authManager, config, protocolConfig }) => new PypiRegistry(
storage,
authManager,
protocolConfig.basePath ?? '/pypi',
protocolConfig.registryUrl ?? 'http://localhost:5000',
config.upstreamProvider
),
},
{
protocol: 'rubygems',
getConfig: (config) => config.rubygems,
matchesPath: (config, path) => path.startsWith(config.rubygems?.basePath ?? '/rubygems'),
create: ({ storage, authManager, config, protocolConfig }) => {
const basePath = protocolConfig.basePath ?? '/rubygems';
return new RubyGemsRegistry(
storage,
authManager,
basePath,
protocolConfig.registryUrl ?? `http://localhost:5000${basePath}`,
config.upstreamProvider
);
},
},
];
/**
* Main registry orchestrator.
* Routes requests to appropriate protocol handlers (OCI, NPM, Maven, Cargo, Composer, PyPI, or RubyGems).
@@ -49,7 +178,7 @@ import { RubyGemsRegistry } from './rubygems/classes.rubygemsregistry.js';
export class SmartRegistry {
private storage: RegistryStorage;
private authManager: AuthManager;
private registries: Map<string, BaseRegistry> = new Map();
private registries: Map<TRegistryProtocol, BaseRegistry> = new Map();
private config: IRegistryConfig;
private initialized: boolean = false;
@@ -75,112 +204,20 @@ export class SmartRegistry {
// Initialize auth manager
await this.authManager.init();
// Initialize OCI registry if enabled
if (this.config.oci?.enabled) {
const ociBasePath = this.config.oci.basePath ?? '/oci';
const ociTokens = this.config.auth.ociTokens?.enabled ? {
realm: this.config.auth.ociTokens.realm,
service: this.config.auth.ociTokens.service,
} : undefined;
const ociRegistry = new OciRegistry(
this.storage,
this.authManager,
ociBasePath,
ociTokens,
this.config.upstreamProvider
);
await ociRegistry.init();
this.registries.set('oci', ociRegistry);
}
for (const descriptor of registryDescriptors) {
const protocolConfig = descriptor.getConfig(this.config);
if (!protocolConfig?.enabled) {
continue;
}
// Initialize NPM registry if enabled
if (this.config.npm?.enabled) {
const npmBasePath = this.config.npm.basePath ?? '/npm';
const registryUrl = this.config.npm.registryUrl ?? `http://localhost:5000${npmBasePath}`;
const npmRegistry = new NpmRegistry(
this.storage,
this.authManager,
npmBasePath,
registryUrl,
this.config.upstreamProvider
);
await npmRegistry.init();
this.registries.set('npm', npmRegistry);
}
// Initialize Maven registry if enabled
if (this.config.maven?.enabled) {
const mavenBasePath = this.config.maven.basePath ?? '/maven';
const registryUrl = this.config.maven.registryUrl ?? `http://localhost:5000${mavenBasePath}`;
const mavenRegistry = new MavenRegistry(
this.storage,
this.authManager,
mavenBasePath,
registryUrl,
this.config.upstreamProvider
);
await mavenRegistry.init();
this.registries.set('maven', mavenRegistry);
}
// Initialize Cargo registry if enabled
if (this.config.cargo?.enabled) {
const cargoBasePath = this.config.cargo.basePath ?? '/cargo';
const registryUrl = this.config.cargo.registryUrl ?? `http://localhost:5000${cargoBasePath}`;
const cargoRegistry = new CargoRegistry(
this.storage,
this.authManager,
cargoBasePath,
registryUrl,
this.config.upstreamProvider
);
await cargoRegistry.init();
this.registries.set('cargo', cargoRegistry);
}
// Initialize Composer registry if enabled
if (this.config.composer?.enabled) {
const composerBasePath = this.config.composer.basePath ?? '/composer';
const registryUrl = this.config.composer.registryUrl ?? `http://localhost:5000${composerBasePath}`;
const composerRegistry = new ComposerRegistry(
this.storage,
this.authManager,
composerBasePath,
registryUrl,
this.config.upstreamProvider
);
await composerRegistry.init();
this.registries.set('composer', composerRegistry);
}
// Initialize PyPI registry if enabled
if (this.config.pypi?.enabled) {
const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
const registryUrl = this.config.pypi.registryUrl ?? `http://localhost:5000`;
const pypiRegistry = new PypiRegistry(
this.storage,
this.authManager,
pypiBasePath,
registryUrl,
this.config.upstreamProvider
);
await pypiRegistry.init();
this.registries.set('pypi', pypiRegistry);
}
// Initialize RubyGems registry if enabled
if (this.config.rubygems?.enabled) {
const rubygemsBasePath = this.config.rubygems.basePath ?? '/rubygems';
const registryUrl = this.config.rubygems.registryUrl ?? `http://localhost:5000${rubygemsBasePath}`;
const rubygemsRegistry = new RubyGemsRegistry(
this.storage,
this.authManager,
rubygemsBasePath,
registryUrl,
this.config.upstreamProvider
);
await rubygemsRegistry.init();
this.registries.set('rubygems', rubygemsRegistry);
const registry = descriptor.create({
storage: this.storage,
authManager: this.authManager,
config: this.config,
protocolConfig,
});
await registry.init();
this.registries.set(descriptor.protocol, registry);
}
this.initialized = true;
@@ -194,62 +231,19 @@ export class SmartRegistry {
const path = context.path;
let response: IResponse | undefined;
// Route to OCI registry
if (!response && this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) {
const ociRegistry = this.registries.get('oci');
if (ociRegistry) {
response = await ociRegistry.handleRequest(context);
for (const descriptor of registryDescriptors) {
if (response) {
break;
}
}
// Route to NPM registry
if (!response && this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) {
const npmRegistry = this.registries.get('npm');
if (npmRegistry) {
response = await npmRegistry.handleRequest(context);
const protocolConfig = descriptor.getConfig(this.config);
if (!protocolConfig?.enabled || !descriptor.matchesPath(this.config, path)) {
continue;
}
}
// Route to Maven registry
if (!response && this.config.maven?.enabled && path.startsWith(this.config.maven.basePath)) {
const mavenRegistry = this.registries.get('maven');
if (mavenRegistry) {
response = await mavenRegistry.handleRequest(context);
}
}
// Route to Cargo registry
if (!response && this.config.cargo?.enabled && path.startsWith(this.config.cargo.basePath)) {
const cargoRegistry = this.registries.get('cargo');
if (cargoRegistry) {
response = await cargoRegistry.handleRequest(context);
}
}
// Route to Composer registry
if (!response && this.config.composer?.enabled && path.startsWith(this.config.composer.basePath)) {
const composerRegistry = this.registries.get('composer');
if (composerRegistry) {
response = await composerRegistry.handleRequest(context);
}
}
// Route to PyPI registry (also handles /simple prefix)
if (!response && this.config.pypi?.enabled) {
const pypiBasePath = this.config.pypi.basePath ?? '/pypi';
if (path.startsWith(pypiBasePath) || path.startsWith('/simple')) {
const pypiRegistry = this.registries.get('pypi');
if (pypiRegistry) {
response = await pypiRegistry.handleRequest(context);
}
}
}
// Route to RubyGems registry
if (!response && this.config.rubygems?.enabled && path.startsWith(this.config.rubygems.basePath)) {
const rubygemsRegistry = this.registries.get('rubygems');
if (rubygemsRegistry) {
response = await rubygemsRegistry.handleRequest(context);
const registry = this.registries.get(descriptor.protocol);
if (registry) {
response = await registry.handleRequest(context);
}
}
@@ -309,9 +303,7 @@ export class SmartRegistry {
*/
public destroy(): void {
for (const registry of this.registries.values()) {
if (typeof (registry as any).destroy === 'function') {
(registry as any).destroy();
}
registry.destroy();
}
}
}
+63 -66
View File
@@ -104,89 +104,86 @@ export class ComposerRegistry extends BaseRegistry {
const path = context.path.replace(this.basePath, '');
// Extract token from Authorization header
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const authHeader = this.getAuthorizationHeader(context);
let token: IAuthToken | null = null;
if (authHeader) {
if (authHeader.startsWith('Bearer ')) {
const tokenString = authHeader.replace(/^Bearer\s+/i, '');
const tokenString = this.extractBearerToken(authHeader);
if (tokenString) {
token = await this.authManager.validateToken(tokenString, 'composer');
} else if (authHeader.startsWith('Basic ')) {
} else {
// Handle HTTP Basic Auth
const credentials = Buffer.from(authHeader.replace(/^Basic\s+/i, ''), 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
const userId = await this.authManager.authenticate({ username, password });
if (userId) {
// Create temporary token for this request
token = {
type: 'composer',
userId,
scopes: ['composer:*:*:read'],
readonly: true,
};
const basicCredentials = this.parseBasicAuthHeader(authHeader);
if (basicCredentials) {
const userId = await this.authManager.authenticate(basicCredentials);
if (userId) {
// Create temporary token for this request
token = {
type: 'composer',
userId,
scopes: ['composer:*:*:read'],
readonly: true,
};
}
}
}
}
// Build actor from context and validated token
const actor: IRequestActor = {
...context.actor,
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
};
const actor: IRequestActor = this.buildRequestActor(context, token);
// Root packages.json
if (path === '/packages.json' || path === '' || path === '/') {
return this.handlePackagesJson();
}
return this.storage.withContext({ protocol: 'composer', actor }, async () => {
// Root packages.json
if (path === '/packages.json' || path === '' || path === '/') {
return this.handlePackagesJson();
}
// Package metadata: /p2/{vendor}/{package}.json or /p2/{vendor}/{package}~dev.json
const metadataMatch = path.match(/^\/p2\/([^\/]+\/[^\/]+?)(~dev)?\.json$/);
if (metadataMatch) {
const [, vendorPackage, devSuffix] = metadataMatch;
const includeDev = !!devSuffix;
return this.handlePackageMetadata(vendorPackage, includeDev, token, actor);
}
// Package metadata: /p2/{vendor}/{package}.json or /p2/{vendor}/{package}~dev.json
const metadataMatch = path.match(/^\/p2\/([^\/]+\/[^\/]+?)(~dev)?\.json$/);
if (metadataMatch) {
const [, vendorPackage, devSuffix] = metadataMatch;
const includeDev = !!devSuffix;
return this.handlePackageMetadata(vendorPackage, includeDev, token, actor);
}
// Package list: /packages/list.json?filter=vendor/*
if (path.startsWith('/packages/list.json')) {
const filter = context.query['filter'];
return this.handlePackageList(filter, token);
}
// Package list: /packages/list.json?filter=vendor/*
if (path.startsWith('/packages/list.json')) {
const filter = context.query['filter'];
return this.handlePackageList(filter, token);
}
// Package ZIP download: /dists/{vendor}/{package}/{reference}.zip
const distMatch = path.match(/^\/dists\/([^\/]+\/[^\/]+)\/([^\/]+)\.zip$/);
if (distMatch) {
const [, vendorPackage, reference] = distMatch;
return this.handlePackageDownload(vendorPackage, reference, token);
}
// Package ZIP download: /dists/{vendor}/{package}/{reference}.zip
const distMatch = path.match(/^\/dists\/([^\/]+\/[^\/]+)\/([^\/]+)\.zip$/);
if (distMatch) {
const [, vendorPackage, reference] = distMatch;
return this.handlePackageDownload(vendorPackage, reference, token);
}
// Package upload: PUT /packages/{vendor}/{package}
const uploadMatch = path.match(/^\/packages\/([^\/]+\/[^\/]+)$/);
if (uploadMatch && context.method === 'PUT') {
const vendorPackage = uploadMatch[1];
return this.handlePackageUpload(vendorPackage, context.body, token);
}
// Package upload: PUT /packages/{vendor}/{package}
const uploadMatch = path.match(/^\/packages\/([^\/]+\/[^\/]+)$/);
if (uploadMatch && context.method === 'PUT') {
const vendorPackage = uploadMatch[1];
return this.handlePackageUpload(vendorPackage, context.body, token);
}
// Package delete: DELETE /packages/{vendor}/{package}
if (uploadMatch && context.method === 'DELETE') {
const vendorPackage = uploadMatch[1];
return this.handlePackageDelete(vendorPackage, token);
}
// Package delete: DELETE /packages/{vendor}/{package}
if (uploadMatch && context.method === 'DELETE') {
const vendorPackage = uploadMatch[1];
return this.handlePackageDelete(vendorPackage, token);
}
// Version delete: DELETE /packages/{vendor}/{package}/{version}
const versionDeleteMatch = path.match(/^\/packages\/([^\/]+\/[^\/]+)\/(.+)$/);
if (versionDeleteMatch && context.method === 'DELETE') {
const [, vendorPackage, version] = versionDeleteMatch;
return this.handleVersionDelete(vendorPackage, version, token);
}
// Version delete: DELETE /packages/{vendor}/{package}/{version}
const versionDeleteMatch = path.match(/^\/packages\/([^\/]+\/[^\/]+)\/(.+)$/);
if (versionDeleteMatch && context.method === 'DELETE') {
const [, vendorPackage, version] = versionDeleteMatch;
return this.handleVersionDelete(vendorPackage, version, token);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { status: 'error', message: 'Not found' },
};
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { status: 'error', message: 'Not found' },
};
});
}
protected async checkPermission(
+118 -1
View File
@@ -1,14 +1,131 @@
import type { IRequestContext, IResponse, IAuthToken } from './interfaces.core.js';
import * as plugins from '../plugins.js';
import type { IRequestContext, IResponse, IAuthToken, IRequestActor } from './interfaces.core.js';
/**
* Abstract base class for all registry protocol implementations
*/
export abstract class BaseRegistry {
protected getHeader(contextOrHeaders: IRequestContext | Record<string, string>, name: string): string | undefined {
const headers = 'headers' in contextOrHeaders ? contextOrHeaders.headers : contextOrHeaders;
if (headers[name] !== undefined) {
return headers[name];
}
const lowerName = name.toLowerCase();
for (const [headerName, value] of Object.entries(headers)) {
if (headerName.toLowerCase() === lowerName) {
return value;
}
}
return undefined;
}
protected getAuthorizationHeader(context: IRequestContext): string | undefined {
return this.getHeader(context, 'authorization');
}
protected getClientIp(context: IRequestContext): string | undefined {
const forwardedFor = this.getHeader(context, 'x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0]?.trim();
}
return this.getHeader(context, 'x-real-ip');
}
protected getUserAgent(context: IRequestContext): string | undefined {
return this.getHeader(context, 'user-agent');
}
protected extractBearerToken(contextOrHeader: IRequestContext | string | undefined): string | null {
const authHeader = typeof contextOrHeader === 'string'
? contextOrHeader
: contextOrHeader
? this.getAuthorizationHeader(contextOrHeader)
: undefined;
if (!authHeader || !/^Bearer\s+/i.test(authHeader)) {
return null;
}
return authHeader.replace(/^Bearer\s+/i, '');
}
protected parseBasicAuthHeader(authHeader: string | undefined): { username: string; password: string } | null {
if (!authHeader || !/^Basic\s+/i.test(authHeader)) {
return null;
}
const base64 = authHeader.replace(/^Basic\s+/i, '');
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const separatorIndex = decoded.indexOf(':');
if (separatorIndex < 0) {
return {
username: decoded,
password: '',
};
}
return {
username: decoded.substring(0, separatorIndex),
password: decoded.substring(separatorIndex + 1),
};
}
protected buildRequestActor(context: IRequestContext, token: IAuthToken | null): IRequestActor {
const actor: IRequestActor = {
...(context.actor ?? {}),
};
if (token?.userId) {
actor.userId = token.userId;
}
const ip = this.getClientIp(context);
if (ip) {
actor.ip = ip;
}
const userAgent = this.getUserAgent(context);
if (userAgent) {
actor.userAgent = userAgent;
}
return actor;
}
protected createProtocolLogger(
containerName: string,
zone: string
): plugins.smartlog.Smartlog {
const logger = new plugins.smartlog.Smartlog({
logContext: {
company: 'push.rocks',
companyunit: 'smartregistry',
containerName,
environment: (process.env.NODE_ENV as any) || 'development',
runtime: 'node',
zone,
}
});
logger.enableConsole();
return logger;
}
/**
* Initialize the registry
*/
abstract init(): Promise<void>;
/**
* Clean up timers, connections, and other registry resources.
*/
public destroy(): void {
// Default no-op for registries without persistent resources.
}
/**
* Handle an incoming HTTP request
* @param context - Request context
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
function digestToHash(digest: string): string {
return digest.split(':')[1];
}
export function getOciBlobPath(digest: string): string {
return `oci/blobs/sha256/${digestToHash(digest)}`;
}
export function getOciManifestPath(repository: string, digest: string): string {
return `oci/manifests/${repository}/${digestToHash(digest)}`;
}
export function getNpmPackumentPath(packageName: string): string {
return `npm/packages/${packageName}/index.json`;
}
export function getNpmTarballPath(packageName: string, version: string): string {
const safeName = packageName.replace('@', '').replace('/', '-');
return `npm/packages/${packageName}/${safeName}-${version}.tgz`;
}
export function getMavenArtifactPath(
groupId: string,
artifactId: string,
version: string,
filename: string
): string {
const groupPath = groupId.replace(/\./g, '/');
return `maven/artifacts/${groupPath}/${artifactId}/${version}/${filename}`;
}
export function getMavenMetadataPath(groupId: string, artifactId: string): string {
const groupPath = groupId.replace(/\./g, '/');
return `maven/metadata/${groupPath}/${artifactId}/maven-metadata.xml`;
}
export function getCargoConfigPath(): string {
return 'cargo/config.json';
}
export function getCargoIndexPath(crateName: string): string {
const lower = crateName.toLowerCase();
const len = lower.length;
if (len === 1) {
return `cargo/index/1/${lower}`;
}
if (len === 2) {
return `cargo/index/2/${lower}`;
}
if (len === 3) {
return `cargo/index/3/${lower.charAt(0)}/${lower}`;
}
const prefix1 = lower.substring(0, 2);
const prefix2 = lower.substring(2, 4);
return `cargo/index/${prefix1}/${prefix2}/${lower}`;
}
export function getCargoCratePath(crateName: string, version: string): string {
return `cargo/crates/${crateName}/${crateName}-${version}.crate`;
}
export function getComposerMetadataPath(vendorPackage: string): string {
return `composer/packages/${vendorPackage}/metadata.json`;
}
export function getComposerZipPath(vendorPackage: string, reference: string): string {
return `composer/packages/${vendorPackage}/${reference}.zip`;
}
export function getPypiMetadataPath(packageName: string): string {
return `pypi/metadata/${packageName}/metadata.json`;
}
export function getPypiSimpleIndexPath(packageName: string): string {
return `pypi/simple/${packageName}/index.html`;
}
export function getPypiSimpleRootIndexPath(): string {
return 'pypi/simple/index.html';
}
export function getPypiPackageFilePath(packageName: string, filename: string): string {
return `pypi/packages/${packageName}/${filename}`;
}
export function getRubyGemsVersionsPath(): string {
return 'rubygems/versions';
}
export function getRubyGemsInfoPath(gemName: string): string {
return `rubygems/info/${gemName}`;
}
export function getRubyGemsNamesPath(): string {
return 'rubygems/names';
}
export function getRubyGemsGemPath(gemName: string, version: string, platform?: string): string {
const filename = platform ? `${gemName}-${version}-${platform}.gem` : `${gemName}-${version}.gem`;
return `rubygems/gems/${filename}`;
}
export function getRubyGemsMetadataPath(gemName: string): string {
return `rubygems/metadata/${gemName}/metadata.json`;
}
+29 -37
View File
@@ -105,56 +105,48 @@ export class MavenRegistry extends BaseRegistry {
// Remove base path from URL
const path = context.path.replace(this.basePath, '');
// Extract token from Authorization header
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const authHeader = this.getAuthorizationHeader(context);
let token: IAuthToken | null = null;
if (authHeader) {
if (/^Basic\s+/i.test(authHeader)) {
const basicCredentials = this.parseBasicAuthHeader(authHeader);
if (basicCredentials) {
// Maven sends Basic Auth: base64(username:password) — extract the password as token
const base64 = authHeader.replace(/^Basic\s+/i, '');
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const colonIndex = decoded.indexOf(':');
const password = colonIndex >= 0 ? decoded.substring(colonIndex + 1) : decoded;
token = await this.authManager.validateToken(password, 'maven');
token = await this.authManager.validateToken(basicCredentials.password, 'maven');
} else {
const tokenString = authHeader.replace(/^Bearer\s+/i, '');
token = await this.authManager.validateToken(tokenString, 'maven');
const tokenString = this.extractBearerToken(authHeader);
token = tokenString ? await this.authManager.validateToken(tokenString, 'maven') : null;
}
}
// Build actor from context and validated token
const actor: IRequestActor = {
...context.actor,
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
};
const actor: IRequestActor = this.buildRequestActor(context, token);
// Parse path to determine request type
const coordinate = pathToGAV(path);
return this.storage.withContext({ protocol: 'maven', actor }, async () => {
// Parse path to determine request type
const coordinate = pathToGAV(path);
if (!coordinate) {
// Not a valid artifact path, could be metadata or root
if (path.endsWith('/maven-metadata.xml')) {
return this.handleMetadataRequest(context.method, path, token, actor);
if (!coordinate) {
// Not a valid artifact path, could be metadata or root
if (path.endsWith('/maven-metadata.xml')) {
return this.handleMetadataRequest(context.method, path, token, actor);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'NOT_FOUND', message: 'Invalid Maven path' },
};
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'NOT_FOUND', message: 'Invalid Maven path' },
};
}
// Check if it's a checksum file
if (coordinate.extension === 'md5' || coordinate.extension === 'sha1' ||
coordinate.extension === 'sha256' || coordinate.extension === 'sha512') {
return this.handleChecksumRequest(context.method, coordinate, token, path);
}
// Check if it's a checksum file
if (coordinate.extension === 'md5' || coordinate.extension === 'sha1' ||
coordinate.extension === 'sha256' || coordinate.extension === 'sha512') {
return this.handleChecksumRequest(context.method, coordinate, token, path);
}
// Handle artifact requests (JAR, POM, WAR, etc.)
return this.handleArtifactRequest(context.method, coordinate, token, context.body, actor);
// Handle artifact requests (JAR, POM, WAR, etc.)
return this.handleArtifactRequest(context.method, coordinate, token, context.body, actor);
});
}
protected async checkPermission(
+204 -222
View File
@@ -7,7 +7,6 @@ import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
import { NpmUpstream } from './classes.npmupstream.js';
import type {
IPackument,
INpmVersion,
IPublishRequest,
ISearchResponse,
ISearchResult,
@@ -16,6 +15,13 @@ import type {
IUserAuthRequest,
INpmError,
} from './interfaces.npm.js';
import {
createNewPackument,
getAttachmentForVersion,
preparePublishedVersion,
recordPublishedVersion,
} from './helpers.npmpublish.js';
import { parseNpmRequestRoute } from './helpers.npmroutes.js';
/**
* NPM Registry implementation
@@ -43,18 +49,7 @@ export class NpmRegistry extends BaseRegistry {
this.registryUrl = registryUrl;
this.upstreamProvider = upstreamProvider || null;
// Initialize logger
this.logger = new Smartlog({
logContext: {
company: 'push.rocks',
companyunit: 'smartregistry',
containerName: 'npm-registry',
environment: (process.env.NODE_ENV as any) || 'development',
runtime: 'node',
zone: 'npm'
}
});
this.logger.enableConsole();
this.logger = this.createProtocolLogger('npm-registry', 'npm');
if (upstreamProvider) {
this.logger.log('info', 'NPM upstream provider configured');
@@ -112,18 +107,10 @@ export class NpmRegistry extends BaseRegistry {
public async handleRequest(context: IRequestContext): Promise<IResponse> {
const path = context.path.replace(this.basePath, '');
// Extract token from Authorization header
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
const tokenString = this.extractBearerToken(context);
const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null;
// Build actor context for upstream resolution
const actor: IRequestActor = {
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['x-real-ip'],
userAgent: context.headers['user-agent'],
...context.actor, // Include any pre-populated actor info
};
const actor: IRequestActor = this.buildRequestActor(context, token);
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
method: context.method,
@@ -131,78 +118,75 @@ export class NpmRegistry extends BaseRegistry {
hasAuth: !!token
});
// Registry root
if (path === '/' || path === '') {
return this.handleRegistryInfo();
}
return this.storage.withContext({ protocol: 'npm', actor }, async () => {
const route = parseNpmRequestRoute(path, context.method);
if (!route) {
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: this.createError('E404', 'Not found'),
};
}
// Search: /-/v1/search
if (path.startsWith('/-/v1/search')) {
return this.handleSearch(context.query);
}
switch (route.type) {
case 'root':
return this.handleRegistryInfo();
case 'search':
return this.handleSearch(context.query);
case 'userAuth':
return this.handleUserAuth(context.method, route.username, context.body, token);
case 'tokens':
return this.handleTokens(context.method, route.path, context.body, token);
case 'distTags':
return this.withPackageContext(
route.packageName,
actor,
async () => this.handleDistTags(context.method, route.packageName, route.tag, context.body, token)
);
case 'tarball':
return this.handleTarballDownload(route.packageName, route.filename, token, actor);
case 'unpublishVersion':
this.logger.log('debug', 'unpublishVersionMatch', {
packageName: route.packageName,
version: route.version,
});
return this.withPackageVersionContext(
route.packageName,
route.version,
actor,
async () => this.unpublishVersion(route.packageName, route.version, token)
);
case 'unpublishPackage':
this.logger.log('debug', 'unpublishPackageMatch', {
packageName: route.packageName,
rev: route.rev,
});
return this.withPackageContext(
route.packageName,
actor,
async () => this.unpublishPackage(route.packageName, token)
);
case 'packageVersion':
this.logger.log('debug', 'versionMatch', {
packageName: route.packageName,
version: route.version,
});
return this.withPackageVersionContext(
route.packageName,
route.version,
actor,
async () => this.handlePackageVersion(route.packageName, route.version, token, actor)
);
case 'package':
this.logger.log('debug', 'packageMatch', { packageName: route.packageName });
return this.withPackageContext(
route.packageName,
actor,
async () => this.handlePackage(context.method, route.packageName, context.body, context.query, token, actor)
);
}
// User authentication: /-/user/org.couchdb.user:{username}
const userMatch = path.match(/^\/-\/user\/org\.couchdb\.user:(.+)$/);
if (userMatch) {
return this.handleUserAuth(context.method, userMatch[1], context.body, token);
}
// Token operations: /-/npm/v1/tokens
if (path.startsWith('/-/npm/v1/tokens')) {
return this.handleTokens(context.method, path, context.body, token);
}
// Dist-tags: /-/package/{package}/dist-tags
const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/);
if (distTagsMatch) {
const [, rawPkgName, tag] = distTagsMatch;
return this.handleDistTags(context.method, decodeURIComponent(rawPkgName), tag, context.body, token);
}
// Tarball download: /{package}/-/{filename}.tgz
const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/);
if (tarballMatch) {
const [, rawPkgName, filename] = tarballMatch;
return this.handleTarballDownload(decodeURIComponent(rawPkgName), filename, token, actor);
}
// Unpublish specific version: DELETE /{package}/-/{version}
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
if (unpublishVersionMatch && context.method === 'DELETE') {
const [, rawPkgName, version] = unpublishVersionMatch;
this.logger.log('debug', 'unpublishVersionMatch', { packageName: decodeURIComponent(rawPkgName), version });
return this.unpublishVersion(decodeURIComponent(rawPkgName), version, token);
}
// Unpublish entire package: DELETE /{package}/-rev/{rev}
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
if (unpublishPackageMatch && context.method === 'DELETE') {
const [, rawPkgName, rev] = unpublishPackageMatch;
this.logger.log('debug', 'unpublishPackageMatch', { packageName: decodeURIComponent(rawPkgName), rev });
return this.unpublishPackage(decodeURIComponent(rawPkgName), token);
}
// Package version: /{package}/{version}
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
if (versionMatch) {
const [, rawPkgName, version] = versionMatch;
this.logger.log('debug', 'versionMatch', { packageName: decodeURIComponent(rawPkgName), version });
return this.handlePackageVersion(decodeURIComponent(rawPkgName), version, token, actor);
}
// Package operations: /{package}
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
if (packageMatch) {
const packageName = decodeURIComponent(packageMatch[1]);
this.logger.log('debug', 'packageMatch', { packageName });
return this.handlePackage(context.method, packageName, context.body, context.query, token, actor);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: this.createError('E404', 'Not found'),
};
});
}
protected async checkPermission(
@@ -268,30 +252,7 @@ export class NpmRegistry extends BaseRegistry {
query: Record<string, string>,
actor?: IRequestActor
): Promise<IResponse> {
let packument = await this.storage.getNpmPackument(packageName);
this.logger.log('debug', `getPackument: ${packageName}`, {
packageName,
found: !!packument,
versions: packument ? Object.keys(packument.versions).length : 0
});
// If not found locally, try upstream
if (!packument) {
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
if (upstream) {
this.logger.log('debug', `getPackument: fetching from upstream`, { packageName });
const upstreamPackument = await upstream.fetchPackument(packageName);
if (upstreamPackument) {
this.logger.log('debug', `getPackument: found in upstream`, {
packageName,
versions: Object.keys(upstreamPackument.versions || {}).length
});
packument = upstreamPackument;
// Optionally cache the packument locally (without tarballs)
// We don't store tarballs here - they'll be fetched on demand
}
}
}
const packument = await this.getLocalOrUpstreamPackument(packageName, actor, 'getPackument');
if (!packument) {
return {
@@ -333,24 +294,12 @@ export class NpmRegistry extends BaseRegistry {
actor?: IRequestActor
): Promise<IResponse> {
this.logger.log('debug', 'handlePackageVersion', { packageName, version });
let packument = await this.storage.getNpmPackument(packageName);
const packument = await this.getLocalOrUpstreamPackument(packageName, actor, 'handlePackageVersion');
this.logger.log('debug', 'handlePackageVersion packument', { found: !!packument });
if (packument) {
this.logger.log('debug', 'handlePackageVersion versions', { versions: Object.keys(packument.versions || {}) });
}
// If not found locally, try upstream
if (!packument) {
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
if (upstream) {
this.logger.log('debug', 'handlePackageVersion: fetching from upstream', { packageName });
const upstreamPackument = await upstream.fetchPackument(packageName);
if (upstreamPackument) {
packument = upstreamPackument;
}
}
}
if (!packument) {
return {
status: 404,
@@ -424,19 +373,7 @@ export class NpmRegistry extends BaseRegistry {
const isNew = !packument;
if (isNew) {
packument = {
_id: packageName,
name: packageName,
description: body.description,
'dist-tags': body['dist-tags'] || { latest: Object.keys(body.versions)[0] },
versions: {},
time: {
created: new Date().toISOString(),
modified: new Date().toISOString(),
},
maintainers: body.maintainers || [],
readme: body.readme,
};
packument = createNewPackument(packageName, body, new Date().toISOString());
}
// Process each new version
@@ -450,12 +387,8 @@ export class NpmRegistry extends BaseRegistry {
};
}
// Find attachment for this version
const attachmentKey = Object.keys(body._attachments).find(key =>
key.includes(version)
);
if (!attachmentKey) {
const attachment = getAttachmentForVersion(body, version);
if (!attachment) {
return {
status: 400,
headers: {},
@@ -463,38 +396,24 @@ export class NpmRegistry extends BaseRegistry {
};
}
const attachment = body._attachments[attachmentKey];
// Decode base64 tarball
const tarballBuffer = Buffer.from(attachment.data, 'base64');
// Calculate shasum
const crypto = await import('crypto');
const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex');
const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`;
const preparedVersion = preparePublishedVersion({
packageName,
version,
versionData,
attachment,
registryUrl: this.registryUrl,
userId: token?.userId,
});
// Store tarball
await this.storage.putNpmTarball(packageName, version, tarballBuffer);
await this.withPackageVersionContext(
packageName,
version,
undefined,
async () => this.storage.putNpmTarball(packageName, version, preparedVersion.tarballBuffer)
);
// Update version data with dist info
const safeName = packageName.replace('@', '').replace('/', '-');
versionData.dist = {
tarball: `${this.registryUrl}/${packageName}/-/${safeName}-${version}.tgz`,
shasum,
integrity,
fileCount: 0,
unpackedSize: tarballBuffer.length,
};
versionData._id = `${packageName}@${version}`;
versionData._npmUser = token ? { name: token.userId, email: '' } : undefined;
// Add version to packument
packument.versions[version] = versionData;
if (packument.time) {
packument.time[version] = new Date().toISOString();
packument.time.modified = new Date().toISOString();
}
recordPublishedVersion(packument, version, preparedVersion.versionData, new Date().toISOString());
}
// Update dist-tags
@@ -632,56 +551,119 @@ export class NpmRegistry extends BaseRegistry {
const version = versionMatch[1];
// Try local storage first (streaming)
const streamResult = await this.storage.getNpmTarballStream(packageName, version);
if (streamResult) {
return {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': streamResult.size.toString(),
},
body: streamResult.stream,
};
}
return this.withPackageVersionContext(
packageName,
version,
actor,
async (): Promise<IResponse> => {
// Try local storage first (streaming)
const streamResult = await this.storage.getNpmTarballStream(packageName, version);
if (streamResult) {
return {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': streamResult.size.toString(),
},
body: streamResult.stream,
};
}
// If not found locally, try upstream
let tarball: Buffer | null = null;
const upstream = await this.getUpstreamForRequest(packageName, 'tarball', 'GET', actor);
if (upstream) {
this.logger.log('debug', 'handleTarballDownload: fetching from upstream', {
packageName,
version,
});
const upstreamTarball = await upstream.fetchTarball(packageName, version);
if (upstreamTarball) {
tarball = upstreamTarball;
// Cache the tarball locally for future requests
await this.storage.putNpmTarball(packageName, version, tarball);
this.logger.log('debug', 'handleTarballDownload: cached tarball locally', {
packageName,
version,
size: tarball.length,
});
// If not found locally, try upstream
let tarball: Buffer | null = null;
const upstream = await this.getUpstreamForRequest(packageName, 'tarball', 'GET', actor);
if (upstream) {
this.logger.log('debug', 'handleTarballDownload: fetching from upstream', {
packageName,
version,
});
const upstreamTarball = await upstream.fetchTarball(packageName, version);
if (upstreamTarball) {
tarball = upstreamTarball;
// Cache the tarball locally for future requests
await this.storage.putNpmTarball(packageName, version, tarball);
this.logger.log('debug', 'handleTarballDownload: cached tarball locally', {
packageName,
version,
size: tarball.length,
});
}
}
if (!tarball) {
return {
status: 404,
headers: {},
body: this.createError('E404', 'Tarball not found'),
};
}
return {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': tarball.length.toString(),
},
body: tarball,
};
}
);
}
private async withPackageContext<T>(
packageName: string,
actor: IRequestActor | undefined,
fn: () => Promise<T>
): Promise<T> {
return this.storage.withContext(
{ protocol: 'npm', actor, metadata: { packageName } },
fn
);
}
private async getLocalOrUpstreamPackument(
packageName: string,
actor: IRequestActor | undefined,
logPrefix: string
): Promise<IPackument | null> {
const localPackument = await this.storage.getNpmPackument(packageName);
this.logger.log('debug', `${logPrefix}: ${packageName}`, {
packageName,
found: !!localPackument,
versions: localPackument ? Object.keys(localPackument.versions).length : 0,
});
if (localPackument) {
return localPackument;
}
if (!tarball) {
return {
status: 404,
headers: {},
body: this.createError('E404', 'Tarball not found'),
};
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
if (!upstream) {
return null;
}
return {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': tarball.length.toString(),
},
body: tarball,
};
this.logger.log('debug', `${logPrefix}: fetching from upstream`, { packageName });
const upstreamPackument = await upstream.fetchPackument(packageName);
if (upstreamPackument) {
this.logger.log('debug', `${logPrefix}: found in upstream`, {
packageName,
versions: Object.keys(upstreamPackument.versions || {}).length,
});
}
return upstreamPackument;
}
private async withPackageVersionContext<T>(
packageName: string,
version: string,
actor: IRequestActor | undefined,
fn: () => Promise<T>
): Promise<T> {
return this.storage.withContext(
{ protocol: 'npm', actor, metadata: { packageName, version } },
fn
);
}
private async handleSearch(query: Record<string, string>): Promise<IResponse> {
+79
View File
@@ -0,0 +1,79 @@
import * as crypto from 'node:crypto';
import type { IPackument, IPublishRequest, INpmVersion } from './interfaces.npm.js';
function getTarballFileName(packageName: string, version: string): string {
const safeName = packageName.replace('@', '').replace('/', '-');
return `${safeName}-${version}.tgz`;
}
export function createNewPackument(
packageName: string,
body: IPublishRequest,
timestamp: string
): IPackument {
return {
_id: packageName,
name: packageName,
description: body.description,
'dist-tags': body['dist-tags'] || { latest: Object.keys(body.versions)[0] },
versions: {},
time: {
created: timestamp,
modified: timestamp,
},
maintainers: body.maintainers || [],
readme: body.readme,
};
}
export function getAttachmentForVersion(
body: IPublishRequest,
version: string
): IPublishRequest['_attachments'][string] | null {
const attachmentKey = Object.keys(body._attachments).find((key) => key.includes(version));
return attachmentKey ? body._attachments[attachmentKey] : null;
}
export function preparePublishedVersion(options: {
packageName: string;
version: string;
versionData: INpmVersion;
attachment: IPublishRequest['_attachments'][string];
registryUrl: string;
userId?: string;
}): { tarballBuffer: Buffer; versionData: INpmVersion } {
const tarballBuffer = Buffer.from(options.attachment.data, 'base64');
const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex');
const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`;
const tarballFileName = getTarballFileName(options.packageName, options.version);
return {
tarballBuffer,
versionData: {
...options.versionData,
dist: {
...options.versionData.dist,
tarball: `${options.registryUrl}/${options.packageName}/-/${tarballFileName}`,
shasum,
integrity,
fileCount: 0,
unpackedSize: tarballBuffer.length,
},
_id: `${options.packageName}@${options.version}`,
...(options.userId ? { _npmUser: { name: options.userId, email: '' } } : {}),
},
};
}
export function recordPublishedVersion(
packument: IPackument,
version: string,
versionData: INpmVersion,
timestamp: string
): void {
packument.versions[version] = versionData;
if (packument.time) {
packument.time[version] = timestamp;
packument.time.modified = timestamp;
}
}
+110
View File
@@ -0,0 +1,110 @@
export type TNpmRequestRoute =
| { type: 'root' }
| { type: 'search' }
| { type: 'userAuth'; username: string }
| { type: 'tokens'; path: string }
| { type: 'distTags'; packageName: string; tag?: string }
| { type: 'tarball'; packageName: string; filename: string }
| { type: 'unpublishVersion'; packageName: string; version: string }
| { type: 'unpublishPackage'; packageName: string; rev: string }
| { type: 'packageVersion'; packageName: string; version: string }
| { type: 'package'; packageName: string };
function decodePackageName(rawPackageName: string): string {
return decodeURIComponent(rawPackageName);
}
export function parseNpmRequestRoute(path: string, method: string): TNpmRequestRoute | null {
if (path === '/' || path === '') {
return { type: 'root' };
}
if (path.startsWith('/-/v1/search')) {
return { type: 'search' };
}
const userMatch = path.match(/^\/-\/user\/org\.couchdb\.user:(.+)$/);
if (userMatch) {
return {
type: 'userAuth',
username: userMatch[1],
};
}
if (path.startsWith('/-/npm/v1/tokens')) {
return {
type: 'tokens',
path,
};
}
const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/);
if (distTagsMatch) {
const [, rawPackageName, tag] = distTagsMatch;
return {
type: 'distTags',
packageName: decodePackageName(rawPackageName),
tag,
};
}
const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/);
if (tarballMatch) {
const [, rawPackageName, filename] = tarballMatch;
return {
type: 'tarball',
packageName: decodePackageName(rawPackageName),
filename,
};
}
if (method === 'DELETE') {
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
if (unpublishVersionMatch) {
const [, rawPackageName, version] = unpublishVersionMatch;
return {
type: 'unpublishVersion',
packageName: decodePackageName(rawPackageName),
version,
};
}
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
if (unpublishPackageMatch) {
const [, rawPackageName, rev] = unpublishPackageMatch;
return {
type: 'unpublishPackage',
packageName: decodePackageName(rawPackageName),
rev,
};
}
}
const unencodedScopedPackageMatch = path.match(/^\/@[^\/]+\/[^\/]+$/);
if (unencodedScopedPackageMatch) {
return {
type: 'package',
packageName: decodePackageName(path.substring(1)),
};
}
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
if (versionMatch) {
const [, rawPackageName, version] = versionMatch;
return {
type: 'packageVersion',
packageName: decodePackageName(rawPackageName),
version,
};
}
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
if (packageMatch) {
return {
type: 'package',
packageName: decodePackageName(packageMatch[1]),
};
}
return null;
}
+55 -72
View File
@@ -42,18 +42,7 @@ export class OciRegistry extends BaseRegistry {
this.ociTokens = ociTokens;
this.upstreamProvider = upstreamProvider || null;
// Initialize logger
this.logger = new Smartlog({
logContext: {
company: 'push.rocks',
companyunit: 'smartregistry',
containerName: 'oci-registry',
environment: (process.env.NODE_ENV as any) || 'development',
runtime: 'node',
zone: 'oci'
}
});
this.logger.enableConsole();
this.logger = this.createProtocolLogger('oci-registry', 'oci');
if (upstreamProvider) {
this.logger.log('info', 'OCI upstream provider configured');
@@ -112,76 +101,70 @@ export class OciRegistry extends BaseRegistry {
// Remove base path from URL
const path = context.path.replace(this.basePath, '');
// Extract token from Authorization header
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
const tokenString = this.extractBearerToken(context);
const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
// Build actor from context and validated token
const actor: IRequestActor = {
...context.actor,
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
};
const actor: IRequestActor = this.buildRequestActor(context, token);
// Route to appropriate handler
// OCI spec: GET /v2/ is the version check endpoint
if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
return this.handleVersionCheck();
}
return this.storage.withContext({ protocol: 'oci', actor }, async () => {
// Route to appropriate handler
// OCI spec: GET /v2/ is the version check endpoint
if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
return this.handleVersionCheck();
}
// Manifest operations: /{name}/manifests/{reference}
const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
if (manifestMatch) {
const [, name, reference] = manifestMatch;
// Prefer rawBody for content-addressable operations to preserve exact bytes
const bodyData = context.rawBody || context.body;
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
}
// Manifest operations: /{name}/manifests/{reference}
const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
if (manifestMatch) {
const [, name, reference] = manifestMatch;
// Prefer rawBody for content-addressable operations to preserve exact bytes
const bodyData = context.rawBody || context.body;
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
}
// Blob operations: /{name}/blobs/{digest}
const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
if (blobMatch) {
const [, name, digest] = blobMatch;
return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
}
// Blob operations: /{name}/blobs/{digest}
const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
if (blobMatch) {
const [, name, digest] = blobMatch;
return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
}
// Blob upload operations: /{name}/blobs/uploads/
const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
if (uploadInitMatch && context.method === 'POST') {
const [, name] = uploadInitMatch;
// Prefer rawBody for content-addressable operations to preserve exact bytes
const bodyData = context.rawBody || context.body;
return this.handleUploadInit(name, token, context.query, bodyData);
}
// Blob upload operations: /{name}/blobs/uploads/
const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
if (uploadInitMatch && context.method === 'POST') {
const [, name] = uploadInitMatch;
// Prefer rawBody for content-addressable operations to preserve exact bytes
const bodyData = context.rawBody || context.body;
return this.handleUploadInit(name, token, context.query, bodyData);
}
// Blob upload operations: /{name}/blobs/uploads/{uuid}
const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
if (uploadMatch) {
const [, name, uploadId] = uploadMatch;
return this.handleUploadSession(context.method, uploadId, token, context);
}
// Blob upload operations: /{name}/blobs/uploads/{uuid}
const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
if (uploadMatch) {
const [, name, uploadId] = uploadMatch;
return this.handleUploadSession(context.method, uploadId, token, context);
}
// Tags list: /{name}/tags/list
const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
if (tagsMatch) {
const [, name] = tagsMatch;
return this.handleTagsList(name, token, context.query);
}
// Tags list: /{name}/tags/list
const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
if (tagsMatch) {
const [, name] = tagsMatch;
return this.handleTagsList(name, token, context.query);
}
// Referrers: /{name}/referrers/{digest}
const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
if (referrersMatch) {
const [, name, digest] = referrersMatch;
return this.handleReferrers(name, digest, token, context.query);
}
// Referrers: /{name}/referrers/{digest}
const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
if (referrersMatch) {
const [, name, digest] = referrersMatch;
return this.handleReferrers(name, digest, token, context.query);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: this.createError('NOT_FOUND', 'Endpoint not found'),
};
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: this.createError('NOT_FOUND', 'Endpoint not found'),
};
});
}
protected async checkPermission(
+2 -1
View File
@@ -1,7 +1,8 @@
// native scope
import * as asyncHooks from 'node:async_hooks';
import * as path from 'path';
export { path };
export { asyncHooks, path };
// @push.rocks scope
import * as smartarchive from '@push.rocks/smartarchive';
+60 -76
View File
@@ -40,18 +40,7 @@ export class PypiRegistry extends BaseRegistry {
this.registryUrl = registryUrl;
this.upstreamProvider = upstreamProvider || null;
// Initialize logger
this.logger = new Smartlog({
logContext: {
company: 'push.rocks',
companyunit: 'smartregistry',
containerName: 'pypi-registry',
environment: (process.env.NODE_ENV as any) || 'development',
runtime: 'node',
zone: 'pypi'
}
});
this.logger.enableConsole();
this.logger = this.createProtocolLogger('pypi-registry', 'pypi');
}
/**
@@ -106,66 +95,62 @@ export class PypiRegistry extends BaseRegistry {
// Extract token (Basic Auth or Bearer)
const token = await this.extractToken(context);
// Build actor from context and validated token
const actor: IRequestActor = {
...context.actor,
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
};
const actor: IRequestActor = this.buildRequestActor(context, token);
// Also handle /simple path prefix
if (path.startsWith('/simple')) {
path = path.replace('/simple', '');
return this.handleSimpleRequest(path, context, actor);
}
return this.storage.withContext({ protocol: 'pypi', actor }, async () => {
// Also handle /simple path prefix
if (path.startsWith('/simple')) {
path = path.replace('/simple', '');
return this.handleSimpleRequest(path, context, actor);
}
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
method: context.method,
path,
hasAuth: !!token
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
method: context.method,
path,
hasAuth: !!token
});
// Root upload endpoint (POST /)
if ((path === '/' || path === '') && context.method === 'POST') {
return this.handleUpload(context, token);
}
// Package metadata JSON API: GET /{package}/json
const jsonMatch = path.match(/^\/([^\/]+)\/json$/);
if (jsonMatch && context.method === 'GET') {
return this.handlePackageJson(jsonMatch[1]);
}
// Version-specific JSON API: GET /{package}/{version}/json
const versionJsonMatch = path.match(/^\/([^\/]+)\/([^\/]+)\/json$/);
if (versionJsonMatch && context.method === 'GET') {
return this.handleVersionJson(versionJsonMatch[1], versionJsonMatch[2]);
}
// Package file download: GET /packages/{package}/{filename}
const downloadMatch = path.match(/^\/packages\/([^\/]+)\/(.+)$/);
if (downloadMatch && context.method === 'GET') {
return this.handleDownload(downloadMatch[1], downloadMatch[2], actor);
}
// Delete package: DELETE /packages/{package}
if (path.match(/^\/packages\/([^\/]+)$/) && context.method === 'DELETE') {
const packageName = path.match(/^\/packages\/([^\/]+)$/)?.[1];
return this.handleDeletePackage(packageName!, token);
}
// Delete version: DELETE /packages/{package}/{version}
const deleteVersionMatch = path.match(/^\/packages\/([^\/]+)\/([^\/]+)$/);
if (deleteVersionMatch && context.method === 'DELETE') {
return this.handleDeleteVersion(deleteVersionMatch[1], deleteVersionMatch[2], token);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'Not Found' },
};
});
// Root upload endpoint (POST /)
if ((path === '/' || path === '') && context.method === 'POST') {
return this.handleUpload(context, token);
}
// Package metadata JSON API: GET /{package}/json
const jsonMatch = path.match(/^\/([^\/]+)\/json$/);
if (jsonMatch && context.method === 'GET') {
return this.handlePackageJson(jsonMatch[1]);
}
// Version-specific JSON API: GET /{package}/{version}/json
const versionJsonMatch = path.match(/^\/([^\/]+)\/([^\/]+)\/json$/);
if (versionJsonMatch && context.method === 'GET') {
return this.handleVersionJson(versionJsonMatch[1], versionJsonMatch[2]);
}
// Package file download: GET /packages/{package}/{filename}
const downloadMatch = path.match(/^\/packages\/([^\/]+)\/(.+)$/);
if (downloadMatch && context.method === 'GET') {
return this.handleDownload(downloadMatch[1], downloadMatch[2], actor);
}
// Delete package: DELETE /packages/{package}
if (path.match(/^\/packages\/([^\/]+)$/) && context.method === 'DELETE') {
const packageName = path.match(/^\/packages\/([^\/]+)$/)?.[1];
return this.handleDeletePackage(packageName!, token);
}
// Delete version: DELETE /packages/{package}/{version}
const deleteVersionMatch = path.match(/^\/packages\/([^\/]+)\/([^\/]+)$/);
if (deleteVersionMatch && context.method === 'DELETE') {
return this.handleDeleteVersion(deleteVersionMatch[1], deleteVersionMatch[2], token);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'Not Found' },
};
}
/**
@@ -358,14 +343,13 @@ export class PypiRegistry extends BaseRegistry {
* Extract authentication token from request
*/
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const authHeader = this.getAuthorizationHeader(context);
if (!authHeader) return null;
// Handle Basic Auth (username:password or __token__:token)
if (authHeader.startsWith('Basic ')) {
const base64 = authHeader.substring(6);
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const [username, password] = decoded.split(':');
const basicCredentials = this.parseBasicAuthHeader(authHeader);
if (basicCredentials) {
const { username, password } = basicCredentials;
// PyPI token authentication: username = __token__
if (username === '__token__') {
@@ -378,8 +362,8 @@ export class PypiRegistry extends BaseRegistry {
}
// Handle Bearer token
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const token = this.extractBearerToken(authHeader);
if (token) {
return this.authManager.validateToken(token, 'pypi');
}
+43 -58
View File
@@ -41,18 +41,7 @@ export class RubyGemsRegistry extends BaseRegistry {
this.registryUrl = registryUrl;
this.upstreamProvider = upstreamProvider || null;
// Initialize logger
this.logger = new Smartlog({
logContext: {
company: 'push.rocks',
companyunit: 'smartregistry',
containerName: 'rubygems-registry',
environment: (process.env.NODE_ENV as any) || 'development',
runtime: 'node',
zone: 'rubygems'
}
});
this.logger.enableConsole();
this.logger = this.createProtocolLogger('rubygems-registry', 'rubygems');
}
/**
@@ -114,13 +103,7 @@ export class RubyGemsRegistry extends BaseRegistry {
// Extract token (Authorization header)
const token = await this.extractToken(context);
// Build actor from context and validated token
const actor: IRequestActor = {
...context.actor,
userId: token?.userId,
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
};
const actor: IRequestActor = this.buildRequestActor(context, token);
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
method: context.method,
@@ -128,52 +111,54 @@ export class RubyGemsRegistry extends BaseRegistry {
hasAuth: !!token
});
// Compact Index endpoints
if (path === '/versions' && context.method === 'GET') {
return this.handleVersionsFile(context);
}
return this.storage.withContext({ protocol: 'rubygems', actor }, async () => {
// Compact Index endpoints
if (path === '/versions' && context.method === 'GET') {
return this.handleVersionsFile(context);
}
if (path === '/names' && context.method === 'GET') {
return this.handleNamesFile();
}
if (path === '/names' && context.method === 'GET') {
return this.handleNamesFile();
}
// Info file: GET /info/{gem}
const infoMatch = path.match(/^\/info\/([^\/]+)$/);
if (infoMatch && context.method === 'GET') {
return this.handleInfoFile(infoMatch[1], actor);
}
// Info file: GET /info/{gem}
const infoMatch = path.match(/^\/info\/([^\/]+)$/);
if (infoMatch && context.method === 'GET') {
return this.handleInfoFile(infoMatch[1], actor);
}
// Gem download: GET /gems/{gem}-{version}[-{platform}].gem
const downloadMatch = path.match(/^\/gems\/(.+\.gem)$/);
if (downloadMatch && context.method === 'GET') {
return this.handleDownload(downloadMatch[1], actor);
}
// Gem download: GET /gems/{gem}-{version}[-{platform}].gem
const downloadMatch = path.match(/^\/gems\/(.+\.gem)$/);
if (downloadMatch && context.method === 'GET') {
return this.handleDownload(downloadMatch[1], actor);
}
// Legacy specs endpoints (Marshal format)
if (path === '/specs.4.8.gz' && context.method === 'GET') {
return this.handleSpecs(false);
}
// Legacy specs endpoints (Marshal format)
if (path === '/specs.4.8.gz' && context.method === 'GET') {
return this.handleSpecs(false);
}
if (path === '/latest_specs.4.8.gz' && context.method === 'GET') {
return this.handleSpecs(true);
}
if (path === '/latest_specs.4.8.gz' && context.method === 'GET') {
return this.handleSpecs(true);
}
// Quick gemspec endpoint: GET /quick/Marshal.4.8/{gem}-{version}.gemspec.rz
const quickMatch = path.match(/^\/quick\/Marshal\.4\.8\/(.+)\.gemspec\.rz$/);
if (quickMatch && context.method === 'GET') {
return this.handleQuickGemspec(quickMatch[1]);
}
// Quick gemspec endpoint: GET /quick/Marshal.4.8/{gem}-{version}.gemspec.rz
const quickMatch = path.match(/^\/quick\/Marshal\.4\.8\/(.+)\.gemspec\.rz$/);
if (quickMatch && context.method === 'GET') {
return this.handleQuickGemspec(quickMatch[1]);
}
// API v1 endpoints
if (path.startsWith('/api/v1/')) {
return this.handleApiRequest(path.substring(7), context, token);
}
// API v1 endpoints
if (path.startsWith('/api/v1/')) {
return this.handleApiRequest(path.substring(7), context, token);
}
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'Not Found' },
};
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'Not Found' },
};
});
}
/**
@@ -192,7 +177,7 @@ export class RubyGemsRegistry extends BaseRegistry {
* Extract authentication token from request
*/
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
const authHeader = this.getAuthorizationHeader(context);
if (!authHeader) return null;
// RubyGems typically uses plain API key in Authorization header