Compare commits

...

8 Commits

Author SHA1 Message Date
jkunz 2e2726a4de v2.9.1
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 14:18:12 +00:00
jkunz 0e35256062 fix(license): add missing MIT license file to repository 2026-04-16 14:18:12 +00:00
jkunz 10190a39fc v2.9.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 10:42:33 +00:00
jkunz 9643ef98b9 feat(registry): add declarative protocol routing and request-scoped storage hook context across registries 2026-04-16 10:42:33 +00:00
jkunz 09335d41f3 v2.8.2
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-27 17:37:24 +00:00
jkunz 2221eef722 fix(maven,tests): handle Maven Basic auth and accept deploy-plugin metadata/checksum uploads while stabilizing npm CLI test cleanup 2026-03-27 17:37:24 +00:00
jkunz 26ddf1a59f v2.8.1
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 23:23:03 +00:00
jkunz 5acd1d6166 fix(registry): align OCI and RubyGems API behavior and improve npm search result ordering 2026-03-24 23:23:03 +00:00
35 changed files with 2825 additions and 2818 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## 2026-04-16 - 2.9.1 - fix(license)
add missing MIT license file to repository
- Adds the project license file to align the repository contents with the package metadata license declaration.
## 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
- Validate Maven tokens from Basic auth credentials by extracting the password portion before token validation.
- Return successful responses for PUT requests to checksum and maven-metadata endpoints so Maven deploy uploads do not fail when files are auto-generated.
- Improve npm CLI integration test isolation and cleanup by using a temporary test directory, copying per-package .npmrc files, and cleaning stale published packages before test runs.
- Tighten test teardown by destroying the registry explicitly and simplifying package/install fixture generation.
## 2026-03-24 - 2.8.1 - fix(registry)
align OCI and RubyGems API behavior and improve npm search result ordering
- handle OCI version checks on /v2 and /v2/ endpoints
- return RubyGems versions JSON in the expected flat array format and update unyank coverage to use the HTTP endpoint
- prioritize exact and prefix matches in npm search results
- update documentation to reflect full upstream proxy support
## 2026-03-24 - 2.8.0 - feat(core,storage,oci,registry-config)
add streaming response support and configurable registry URLs across protocols
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2025 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartregistry",
"version": "2.8.0",
"version": "2.9.1",
"private": false,
"description": "A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries",
"main": "dist_ts/index.js",
+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==}
+312 -775
View File
File diff suppressed because it is too large Load Diff
+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 };
}
+1 -1
View File
@@ -268,8 +268,8 @@ tap.test('Cargo: should store crate in smarts3', async () => {
* Cleanup: Stop smartstorage server
*/
tap.test('should stop smartstorage server', async () => {
registry.destroy();
await s3Server.stop();
expect(true).toEqual(true);
});
export default tap.start();
+78 -97
View File
@@ -6,14 +6,14 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
import { SmartRegistry } from '../ts/index.js';
import { createTestRegistry, createTestTokens } from './helpers/registry.js';
import { createTestRegistry, createTestTokens, cleanupS3Bucket } from './helpers/registry.js';
import type { IRequestContext, IResponse } from '../ts/core/interfaces.core.js';
import * as http from 'http';
import * as url from 'url';
import * as fs from 'fs';
import * as path from 'path';
// Test context
// Test state
let registry: SmartRegistry;
let server: http.Server;
let registryUrl: string;
@@ -32,21 +32,22 @@ async function createHttpServer(
return new Promise((resolve, reject) => {
const httpServer = http.createServer(async (req, res) => {
try {
// Parse request
const parsedUrl = url.parse(req.url || '', true);
const pathname = parsedUrl.pathname || '/';
const query = parsedUrl.query;
const parsedUrl = new url.URL(req.url || '/', `http://localhost:${port}`);
const pathname = parsedUrl.pathname;
const query: Record<string, string> = {};
parsedUrl.searchParams.forEach((value, key) => {
query[key] = value;
});
// Read body
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const bodyBuffer = Buffer.concat(chunks);
let body: any = undefined;
if (req.method === 'PUT' || req.method === 'POST' || req.method === 'PATCH') {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.from(chunk));
}
const bodyBuffer = Buffer.concat(chunks);
// Parse body based on content type
let body: any;
if (bodyBuffer.length > 0) {
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/json')) {
try {
@@ -124,7 +125,7 @@ function createTestPackage(
version: string,
targetDir: string
): string {
const packageDir = path.join(targetDir, packageName);
const packageDir = path.join(targetDir, packageName.replace(/\//g, '-'));
fs.mkdirSync(packageDir, { recursive: true });
// Create package.json
@@ -133,12 +134,7 @@ function createTestPackage(
version: version,
description: `Test package ${packageName}`,
main: 'index.js',
scripts: {
test: 'echo "Test passed"',
},
keywords: ['test'],
author: 'Test Author',
license: 'MIT',
scripts: {},
};
fs.writeFileSync(
@@ -147,25 +143,24 @@ function createTestPackage(
'utf-8'
);
// Create index.js
const indexJs = `module.exports = {
name: '${packageName}',
version: '${version}',
message: 'Hello from ${packageName}@${version}'
};
`;
fs.writeFileSync(path.join(packageDir, 'index.js'), indexJs, 'utf-8');
// Create a simple index.js
fs.writeFileSync(
path.join(packageDir, 'index.js'),
`module.exports = { name: '${packageName}', version: '${version}' };\n`,
'utf-8'
);
// Create README.md
const readme = `# ${packageName}
fs.writeFileSync(
path.join(packageDir, 'README.md'),
`# ${packageName}\n\nTest package version ${version}\n`,
'utf-8'
);
Test package for SmartRegistry.
Version: ${version}
`;
fs.writeFileSync(path.join(packageDir, 'README.md'), readme, 'utf-8');
// Copy .npmrc into the package directory
if (npmrcPath && fs.existsSync(npmrcPath)) {
fs.copyFileSync(npmrcPath, path.join(packageDir, '.npmrc'));
}
return packageDir;
}
@@ -177,31 +172,30 @@ async function runNpmCommand(
command: string,
cwd: string
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
// Prepare environment variables
const envVars = [
`NPM_CONFIG_USERCONFIG="${npmrcPath}"`,
`NPM_CONFIG_CACHE="${path.join(testDir, '.npm-cache')}"`,
`NPM_CONFIG_PREFIX="${path.join(testDir, '.npm-global')}"`,
`NPM_CONFIG_REGISTRY="${registryUrl}/npm/"`,
].join(' ');
const { exec } = await import('child_process');
// Build command with cd to correct directory and environment variables
const fullCommand = `cd "${cwd}" && ${envVars} ${command}`;
try {
const result = await tapNodeTools.runCommand(fullCommand);
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.exitCode || 0,
};
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || String(error),
exitCode: error.exitCode || 1,
};
// Build isolated env that prevents npm from reading ~/.npmrc
const env: Record<string, string> = {};
// Copy only essential env vars (PATH, etc.) — exclude HOME to prevent ~/.npmrc reading
for (const key of ['PATH', 'NODE', 'NVM_DIR', 'NVM_BIN', 'LANG', 'TERM', 'SHELL']) {
if (process.env[key]) env[key] = process.env[key]!;
}
env.HOME = testDir;
env.NPM_CONFIG_USERCONFIG = npmrcPath;
env.NPM_CONFIG_GLOBALCONFIG = '/dev/null';
env.NPM_CONFIG_CACHE = path.join(testDir, '.npm-cache');
env.NPM_CONFIG_PREFIX = path.join(testDir, '.npm-global');
env.NPM_CONFIG_REGISTRY = `${registryUrl}/npm/`;
return new Promise((resolve) => {
exec(command, { cwd, env, timeout: 30000 }, (error, stdout, stderr) => {
resolve({
stdout: stdout || '',
stderr: stderr || '',
exitCode: error ? (error as any).code ?? 1 : 0,
});
});
});
}
/**
@@ -226,6 +220,16 @@ tap.test('NPM CLI: should setup registry and HTTP server', async () => {
const tokens = await createTestTokens(registry);
npmToken = tokens.npmToken;
// Clean up stale npm CLI test data via unpublish API
for (const pkg of ['test-package-cli', '@testscope%2fscoped-package']) {
await registry.handleRequest({
method: 'DELETE',
path: `/npm/${pkg}/-rev/cleanup`,
headers: { Authorization: `Bearer ${npmToken}` },
query: {},
});
}
expect(registry).toBeInstanceOf(SmartRegistry);
expect(npmToken).toBeTypeOf('string');
const serverSetup = await createHttpServer(registry, registryPort);
@@ -235,8 +239,8 @@ tap.test('NPM CLI: should setup registry and HTTP server', async () => {
expect(server).toBeDefined();
expect(registryUrl).toEqual(`http://localhost:${registryPort}`);
// Setup test directory
testDir = path.join(process.cwd(), '.nogit', 'test-npm-cli');
// Setup test directory — use /tmp to isolate from project tree
testDir = path.join('/tmp', 'smartregistry-test-npm-cli');
cleanupTestDir(testDir);
fs.mkdirSync(testDir, { recursive: true });
@@ -285,20 +289,16 @@ tap.test('NPM CLI: should install published package', async () => {
const installDir = path.join(testDir, 'install-test');
fs.mkdirSync(installDir, { recursive: true });
// Create package.json for installation
const packageJson = {
name: 'install-test',
version: '1.0.0',
dependencies: {
[packageName]: '1.0.0',
},
};
// Create a minimal package.json for install target
fs.writeFileSync(
path.join(installDir, 'package.json'),
JSON.stringify(packageJson, null, 2),
JSON.stringify({ name: 'install-test', version: '1.0.0', dependencies: { [packageName]: '1.0.0' } }),
'utf-8'
);
// Copy .npmrc
if (npmrcPath && fs.existsSync(npmrcPath)) {
fs.copyFileSync(npmrcPath, path.join(installDir, '.npmrc'));
}
const result = await runNpmCommand('npm install', installDir);
console.log('npm install output:', result.stdout);
@@ -307,17 +307,8 @@ tap.test('NPM CLI: should install published package', async () => {
expect(result.exitCode).toEqual(0);
// Verify package was installed
const nodeModulesPath = path.join(installDir, 'node_modules', packageName);
expect(fs.existsSync(nodeModulesPath)).toEqual(true);
expect(fs.existsSync(path.join(nodeModulesPath, 'package.json'))).toEqual(true);
expect(fs.existsSync(path.join(nodeModulesPath, 'index.js'))).toEqual(true);
// Verify package contents
const installedPackageJson = JSON.parse(
fs.readFileSync(path.join(nodeModulesPath, 'package.json'), 'utf-8')
);
expect(installedPackageJson.name).toEqual(packageName);
expect(installedPackageJson.version).toEqual('1.0.0');
const installed = fs.existsSync(path.join(installDir, 'node_modules', packageName, 'package.json'));
expect(installed).toEqual(true);
});
tap.test('NPM CLI: should publish second version', async () => {
@@ -369,17 +360,14 @@ tap.test('NPM CLI: should fail to publish without auth', async () => {
const version = '1.0.0';
const packageDir = createTestPackage(packageName, version, testDir);
// Temporarily remove .npmrc
const npmrcBackup = fs.readFileSync(npmrcPath, 'utf-8');
fs.writeFileSync(npmrcPath, 'registry=' + registryUrl + '/npm/\n', 'utf-8');
// Temporarily remove .npmrc (write one without auth)
const noAuthNpmrc = path.join(packageDir, '.npmrc');
fs.writeFileSync(noAuthNpmrc, `registry=${registryUrl}/npm/\n`, 'utf-8');
const result = await runNpmCommand('npm publish', packageDir);
console.log('npm publish unauth output:', result.stdout);
console.log('npm publish unauth stderr:', result.stderr);
// Restore .npmrc
fs.writeFileSync(npmrcPath, npmrcBackup, 'utf-8');
// Should fail with auth error
expect(result.exitCode).not.toEqual(0);
});
@@ -393,14 +381,7 @@ tap.postTask('cleanup npm cli tests', async () => {
}
// Cleanup test directory
if (testDir) {
cleanupTestDir(testDir);
}
// Destroy registry
if (registry) {
registry.destroy();
}
cleanupTestDir(testDir);
});
export default tap.start();
+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',
+21 -14
View File
@@ -148,11 +148,16 @@ async function runGemCommand(
cwd: string,
includeAuth: boolean = true
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
// When not including auth, use a temp HOME without credentials
const effectiveHome = includeAuth ? gemHome : path.join(gemHome, 'noauth');
if (!includeAuth) {
fs.mkdirSync(effectiveHome, { recursive: true });
}
// Prepare environment variables
const envVars = [
`HOME="${gemHome}"`,
`HOME="${effectiveHome}"`,
`GEM_HOME="${gemHome}"`,
includeAuth ? '' : 'RUBYGEMS_API_KEY=""',
].filter(Boolean).join(' ');
// Build command with cd to correct directory and environment variables
@@ -360,31 +365,33 @@ tap.test('RubyGems CLI: should unyank a version', async () => {
const gemName = 'test-gem-cli';
const version = '1.0.0';
const result = await runGemCommand(
`gem yank ${gemName} -v ${version} --undo --host ${registryUrl}/rubygems`,
testDir
// Use PUT /api/v1/gems/unyank via HTTP API (gem yank --undo removed in Ruby 4.0)
const response = await fetch(
`${registryUrl}/rubygems/api/v1/gems/unyank?gem_name=${gemName}&version=${version}`,
{
method: 'PUT',
headers: {
'Authorization': rubygemsToken,
},
}
);
console.log('gem unyank output:', result.stdout);
console.log('gem unyank stderr:', result.stderr);
console.log('gem unyank status:', response.status);
expect(result.exitCode).toEqual(0);
expect(response.status).toEqual(200);
// Verify version is not yanked in /versions file
const response = await fetch(`${registryUrl}/rubygems/versions`);
const versionsData = await response.text();
const versionsResponse = await fetch(`${registryUrl}/rubygems/versions`);
const versionsData = await versionsResponse.text();
console.log('Versions after unyank:', versionsData);
// Should not have '-' prefix anymore (or have both without prefix)
// Check that we have the version without yank marker
// Should not have '-' prefix anymore
const lines = versionsData.trim().split('\n');
const gemLine = lines.find(line => line.startsWith(gemName));
if (gemLine) {
// Parse format: "gemname version[,version...] md5"
const parts = gemLine.split(' ');
const versions = parts[1];
// Should have 1.0.0 without '-' prefix
expect(versions).toContain('1.0.0');
expect(versions).not.toContain('-1.0.0');
}
+3 -7
View File
@@ -324,13 +324,9 @@ tap.test('RubyGems: should retrieve versions JSON (GET /rubygems/api/v1/versions
expect(response.status).toEqual(200);
expect(response.headers['Content-Type']).toEqual('application/json');
const json = await streamToJson(response.body);
expect(json).toBeTypeOf('object');
expect(json).toHaveProperty('name');
expect(json.name).toEqual(testGemName);
expect(json).toHaveProperty('versions');
expect(json.versions).toBeTypeOf('object');
expect(json.versions.length).toBeGreaterThan(0);
expect(json).toBeInstanceOf(Array);
expect(json.length).toBeGreaterThan(0);
expect(json[0]).toHaveProperty('number');
});
tap.test('RubyGems: should retrieve dependencies JSON (GET /rubygems/api/v1/dependencies)', async () => {
+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.0',
version: '2.9.1',
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`;
}
+54 -34
View File
@@ -105,48 +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) {
const tokenString = authHeader.replace(/^(Bearer|Basic)\s+/i, '');
// For now, try to validate as Maven token (reuse npm token type)
token = await this.authManager.validateToken(tokenString, 'maven');
const basicCredentials = this.parseBasicAuthHeader(authHeader);
if (basicCredentials) {
// Maven sends Basic Auth: base64(username:password) — extract the password as token
token = await this.authManager.validateToken(basicCredentials.password, 'maven');
} else {
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(
@@ -240,9 +240,19 @@ export class MavenRegistry extends BaseRegistry {
return this.getChecksum(groupId, artifactId, version, coordinate, path);
}
// Accept PUT silently — Maven deploy-plugin uploads checksums alongside artifacts,
// but our registry auto-generates them, so we just acknowledge the upload
if (method === 'PUT') {
return {
status: 200,
headers: {},
body: { status: 'ok' },
};
}
return {
status: 405,
headers: { 'Allow': 'GET, HEAD' },
headers: { 'Allow': 'GET, HEAD, PUT' },
body: { error: 'METHOD_NOT_ALLOWED', message: 'Checksums are auto-generated' },
};
}
@@ -275,9 +285,19 @@ export class MavenRegistry extends BaseRegistry {
return this.getMetadata(groupId, artifactId, actor);
}
// Accept PUT silently — Maven deploy-plugin uploads maven-metadata.xml,
// but our registry auto-generates it, so we just acknowledge the upload
if (method === 'PUT') {
return {
status: 200,
headers: {},
body: { status: 'ok' },
};
}
return {
status: 405,
headers: { 'Allow': 'GET' },
headers: { 'Allow': 'GET, PUT' },
body: { error: 'METHOD_NOT_ALLOWED', message: 'Metadata is auto-generated' },
};
}
+220 -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> {
@@ -749,6 +731,22 @@ export class NpmRegistry extends BaseRegistry {
this.logger.log('error', 'handleSearch failed', { error: (error as Error).message });
}
// Sort results by relevance: exact match first, then prefix match, then substring match
if (text) {
const lowerText = text.toLowerCase();
results.sort((a, b) => {
const aName = a.package.name.toLowerCase();
const bName = b.package.name.toLowerCase();
const aExact = aName === lowerText ? 0 : 1;
const bExact = bName === lowerText ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
const aPrefix = aName.startsWith(lowerText) ? 0 : 1;
const bPrefix = bName.startsWith(lowerText) ? 0 : 1;
if (aPrefix !== bPrefix) return aPrefix - bPrefix;
return aName.localeCompare(bName);
});
}
// Apply pagination
const paginatedResults = results.slice(from, from + size);
+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 -71
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,75 +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
if (path === '/' || path === '') {
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
+6 -8
View File
@@ -254,14 +254,12 @@ export function generateVersionsJson(
uploadTime?: string;
}>
): any {
return {
name: gemName,
versions: versions.map(v => ({
number: v.version,
platform: v.platform || 'ruby',
built_at: v.uploadTime,
})),
};
// RubyGems.org API returns a flat array at /api/v1/versions/{gem}.json
return versions.map(v => ({
number: v.version,
platform: v.platform || 'ruby',
built_at: v.uploadTime,
}));
}
/**