diff --git a/changelog.md b/changelog.md
index c241cf8..dbba547 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,14 @@
# Changelog
+## 2026-04-16 - 2.9.0 - feat(registry)
+add declarative protocol routing and request-scoped storage hook context across registries
+
+- Refactors protocol registration and request dispatch in SmartRegistry around shared registry descriptors.
+- Wraps protocol request handling in storage context so hooks receive protocol, actor, package, and version metadata without cross-request leakage.
+- Adds shared base registry helpers for header parsing, bearer/basic auth extraction, actor construction, and protocol logger creation.
+- Improves NPM route parsing and publish helpers, including support for unencoded scoped package metadata and publish paths.
+- Introduces centralized registry storage path helpers and expands test helpers and coverage for concurrent context isolation and real request hook metadata.
+
## 2026-03-27 - 2.8.2 - fix(maven,tests)
handle Maven Basic auth and accept deploy-plugin metadata/checksum uploads while stabilizing npm CLI test cleanup
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7c7d13e..2806fc4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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==}
diff --git a/readme.md b/readme.md
index 7b5c3c8..cffefaf 100644
--- a/readme.md
+++ b/readme.md
@@ -22,6 +22,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Shared Storage**: Cloud-agnostic S3-compatible backend via [@push.rocks/smartbucket](https://www.npmjs.com/package/@push.rocks/smartbucket) with standardized `IS3Descriptor` from [@tsclass/tsclass](https://www.npmjs.com/package/@tsclass/tsclass)
- **Unified Authentication**: Scope-based permissions across all protocols
- **Path-based Routing**: `/oci/*`, `/npm/*`, `/maven/*`, `/cargo/*`, `/composer/*`, `/pypi/*`, `/rubygems/*`
+- **Declarative Protocol Wiring**: Protocol registration, initialization, and routing stay aligned through shared descriptors
### 🔐 Authentication & Authorization
- NPM UUID tokens for package operations
@@ -60,6 +61,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🔌 Enterprise Extensibility
- **Storage Event Hooks** (`IStorageHooks`): Quota tracking, audit logging, virus scanning, cache invalidation
- **Request Actor Context**: Pass user/org info through requests for audit trails and rate limiting
+- **Request-Scoped Hook Metadata**: Hooks receive protocol, actor, package, and version context without cross-request leakage
## 📥 Installation
@@ -233,6 +235,9 @@ const search = await registry.handleRequest({
});
```
+Scoped package requests are supported with both encoded and unencoded paths, for example
+`/npm/@scope%2fpackage` and `/npm/@scope/package`.
+
### 🦀 Cargo Registry (Rust Crates)
```typescript
diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts
new file mode 100644
index 0000000..bbaf309
--- /dev/null
+++ b/test/helpers/fixtures.ts
@@ -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 `
+
+ 4.0.0
+ ${groupId}
+ ${artifactId}
+ ${version}
+ ${packaging}
+ ${artifactId}
+ Test Maven artifact
+`;
+}
+
+/**
+ * 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 {
+ 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 = ` {
+ 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 {
+ 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 {
+ 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'),
+ };
+}
diff --git a/test/helpers/ids.ts b/test/helpers/ids.ts
new file mode 100644
index 0000000..c135250
--- /dev/null
+++ b/test/helpers/ids.ts
@@ -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}`;
+}
diff --git a/test/helpers/providers.ts b/test/helpers/providers.ts
new file mode 100644
index 0000000..36fdca3
--- /dev/null
+++ b/test/helpers/providers.ts
@@ -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, 'id' | 'url' | 'priority' | 'enabled'> &
+ Pick;
+
+type TTestProtocolUpstreamConfig = Omit & {
+ 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>
+): {
+ 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 {
+ const tokens = new Map();
+
+ 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,
+ };
+}
diff --git a/test/helpers/registry.ts b/test/helpers/registry.ts
index 1e99981..cf1f55c 100644
--- a/test/helpers/registry.ts
+++ b/test/helpers/registry.ts
@@ -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 {
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 {
});
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 {
*/
export async function createTestRegistry(options?: {
registryUrl?: string;
+ storageHooks?: IStorageHooks;
}): Promise {
- // 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 {
- // 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>
-): {
- 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 `
-
- 4.0.0
- ${groupId}
- ${artifactId}
- ${version}
- ${packaging}
- ${artifactId}
- Test Maven artifact
-`;
-}
-
-/**
- * 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 {
- 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 = ` {
- 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 {
- 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 {
- 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 {
- const tokens = new Map();
-
- 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;
- putObject: (key: string, data: Buffer) => Promise;
- deleteObject: (key: string) => Promise;
- listObjects: (prefix: string) => Promise;
- };
- bucket: smartbucket.Bucket;
- cleanup: () => Promise;
-}> {
- 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 => {
- 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 => {
- await bucket.fastPut({ path: key, contents: data, overwrite: true });
- },
- deleteObject: async (key: string): Promise => {
- await bucket.fastRemove({ path: key });
- },
- listObjects: async (prefix: string): Promise => {
- 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 };
-}
diff --git a/test/helpers/registryconfig.ts b/test/helpers/registryconfig.ts
new file mode 100644
index 0000000..3244d76
--- /dev/null
+++ b/test/helpers/registryconfig.ts
@@ -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 {
+ 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 {
+ 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;
+}
diff --git a/test/helpers/storagebackend.ts b/test/helpers/storagebackend.ts
new file mode 100644
index 0000000..c7c9c5e
--- /dev/null
+++ b/test/helpers/storagebackend.ts
@@ -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;
+ putObject: (key: string, data: Buffer) => Promise;
+ deleteObject: (key: string) => Promise;
+ listObjects: (prefix: string) => Promise;
+ };
+ bucket: smartbucket.Bucket;
+ cleanup: () => Promise;
+}> {
+ 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 => {
+ try {
+ return await bucket.fastGet({ path: key });
+ } catch {
+ return null;
+ }
+ },
+ putObject: async (key: string, data: Buffer): Promise => {
+ await bucket.fastPut({ path: key, contents: data, overwrite: true });
+ },
+ deleteObject: async (key: string): Promise => {
+ await bucket.fastRemove({ path: key });
+ },
+ listObjects: async (prefix: string): Promise => {
+ 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 };
+}
diff --git a/test/helpers/storagehooks.ts b/test/helpers/storagehooks.ts
new file mode 100644
index 0000000..e1a3027
--- /dev/null
+++ b/test/helpers/storagehooks.ts
@@ -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;
+ },
+ },
+ };
+}
diff --git a/test/helpers/tokens.ts b/test/helpers/tokens.ts
new file mode 100644
index 0000000..a72694a
--- /dev/null
+++ b/test/helpers/tokens.ts
@@ -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 };
+}
diff --git a/test/test.npm.ts b/test/test.npm.ts
index 81339d1..e202953 100644
--- a/test/test.npm.ts
+++ b/test/test.npm.ts
@@ -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',
diff --git a/test/test.storage.hooks.ts b/test/test.storage.hooks.ts
index da607e0..fa5ee3c 100644
--- a/test/test.storage.hooks.ts
+++ b/test/test.storage.hooks.ts
@@ -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((resolve) => {
+ startedResolve = resolve;
+ });
+ const bothWritesWaiting = new Promise((resolve) => {
+ waitingResolve = resolve;
+ });
+
+ bucket.fastPut = async (options: any) => {
+ startedWrites += 1;
+ if (startedWrites === 2) {
+ startedResolve();
+ }
+
+ await bothWritesStarted;
+
+ await new Promise((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
// ============================================================================
diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts
index 5733774..349abe4 100644
--- a/ts/00_commitinfo_data.ts
+++ b/ts/00_commitinfo_data.ts
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartregistry',
- version: '2.8.2',
+ version: '2.9.0',
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
}
diff --git a/ts/cargo/classes.cargoregistry.ts b/ts/cargo/classes.cargoregistry.ts
index d87d0a2..ae49af1 100644
--- a/ts/cargo/classes.cargoregistry.ts
+++ b/ts/cargo/classes.cargoregistry.ts
@@ -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);
+ });
}
/**
diff --git a/ts/classes.smartregistry.ts b/ts/classes.smartregistry.ts
index 95348ce..06f7728 100644
--- a/ts/classes.smartregistry.ts
+++ b/ts/classes.smartregistry.ts
@@ -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 = new Map();
+ private registries: Map = 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();
}
}
}
diff --git a/ts/composer/classes.composerregistry.ts b/ts/composer/classes.composerregistry.ts
index 1774248..3d8d92b 100644
--- a/ts/composer/classes.composerregistry.ts
+++ b/ts/composer/classes.composerregistry.ts
@@ -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(
diff --git a/ts/core/classes.baseregistry.ts b/ts/core/classes.baseregistry.ts
index b36f44c..446f7e2 100644
--- a/ts/core/classes.baseregistry.ts
+++ b/ts/core/classes.baseregistry.ts
@@ -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, 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;
+ /**
+ * 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
diff --git a/ts/core/classes.registrystorage.ts b/ts/core/classes.registrystorage.ts
index b3384d6..87a1704 100644
--- a/ts/core/classes.registrystorage.ts
+++ b/ts/core/classes.registrystorage.ts
@@ -1,4 +1,5 @@
import * as plugins from '../plugins.js';
+import * as registryStoragePaths from './helpers.registrystoragepaths.js';
import type { IStorageConfig, IStorageBackend, TRegistryProtocol } from './interfaces.core.js';
import type {
IStorageHooks,
@@ -7,6 +8,12 @@ import type {
IStorageMetadata,
} from './interfaces.storage.js';
+type TStorageOperationContext = {
+ protocol: TRegistryProtocol;
+ actor?: IStorageActor;
+ metadata?: IStorageMetadata;
+};
+
/**
* Storage abstraction layer for registry.
* Provides a unified interface over SmartBucket with optional hooks
@@ -38,6 +45,7 @@ export class RegistryStorage implements IStorageBackend {
private bucket!: plugins.smartbucket.Bucket;
private bucketName: string;
private hooks?: IStorageHooks;
+ private readonly contextStorage = new plugins.asyncHooks.AsyncLocalStorage();
constructor(private config: IStorageConfig, hooks?: IStorageHooks) {
this.bucketName = config.bucketName;
@@ -70,22 +78,14 @@ export class RegistryStorage implements IStorageBackend {
* Get an object from storage
*/
public async getObject(key: string): Promise {
+ const context = this.getCurrentContext();
+
try {
const data = await this.bucket.fastGet({ path: key });
// Call afterGet hook (non-blocking)
- if (this.hooks?.afterGet && data) {
- const context = this.currentContext;
- if (context) {
- this.hooks.afterGet({
- operation: 'get',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: context.metadata,
- timestamp: new Date(),
- }).catch(() => {}); // Don't fail on hook errors
- }
+ if (this.hooks?.afterGet && data && context) {
+ this.hooks.afterGet(this.buildHookContext('get', key, context)).catch(() => {}); // Don't fail on hook errors
}
return data;
@@ -102,26 +102,20 @@ export class RegistryStorage implements IStorageBackend {
data: Buffer,
metadata?: Record
): Promise {
- // Call beforePut hook if available
- if (this.hooks?.beforePut) {
- const context = this.currentContext;
- if (context) {
- const hookContext: IStorageHookContext = {
- operation: 'put',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: {
- ...context.metadata,
- size: data.length,
- },
- timestamp: new Date(),
- };
+ const context = this.getCurrentContext();
+ let hookMetadata: IStorageMetadata | undefined = context ? {
+ ...(context.metadata ?? {}),
+ size: data.length,
+ } : undefined;
- const result = await this.hooks.beforePut(hookContext);
- if (!result.allowed) {
- throw new Error(result.reason || 'Storage operation denied by hook');
- }
+ // Call beforePut hook if available
+ if (this.hooks?.beforePut && context) {
+ const result = await this.hooks.beforePut(this.buildHookContext('put', key, context, hookMetadata));
+ if (!result.allowed) {
+ throw new Error(result.reason || 'Storage operation denied by hook');
+ }
+ if (result.metadata) {
+ hookMetadata = { ...(hookMetadata ?? {}), ...result.metadata };
}
}
@@ -133,21 +127,8 @@ export class RegistryStorage implements IStorageBackend {
});
// Call afterPut hook (non-blocking)
- if (this.hooks?.afterPut) {
- const context = this.currentContext;
- if (context) {
- this.hooks.afterPut({
- operation: 'put',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: {
- ...context.metadata,
- size: data.length,
- },
- timestamp: new Date(),
- }).catch(() => {}); // Don't fail on hook errors
- }
+ if (this.hooks?.afterPut && context) {
+ this.hooks.afterPut(this.buildHookContext('put', key, context, hookMetadata)).catch(() => {}); // Don't fail on hook errors
}
}
@@ -155,41 +136,21 @@ export class RegistryStorage implements IStorageBackend {
* Delete an object
*/
public async deleteObject(key: string): Promise {
- // Call beforeDelete hook if available
- if (this.hooks?.beforeDelete) {
- const context = this.currentContext;
- if (context) {
- const hookContext: IStorageHookContext = {
- operation: 'delete',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: context.metadata,
- timestamp: new Date(),
- };
+ const context = this.getCurrentContext();
- const result = await this.hooks.beforeDelete(hookContext);
- if (!result.allowed) {
- throw new Error(result.reason || 'Delete operation denied by hook');
- }
+ // Call beforeDelete hook if available
+ if (this.hooks?.beforeDelete && context) {
+ const result = await this.hooks.beforeDelete(this.buildHookContext('delete', key, context));
+ if (!result.allowed) {
+ throw new Error(result.reason || 'Delete operation denied by hook');
}
}
await this.bucket.fastRemove({ path: key });
// Call afterDelete hook (non-blocking)
- if (this.hooks?.afterDelete) {
- const context = this.currentContext;
- if (context) {
- this.hooks.afterDelete({
- operation: 'delete',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: context.metadata,
- timestamp: new Date(),
- }).catch(() => {}); // Don't fail on hook errors
- }
+ if (this.hooks?.afterDelete && context) {
+ this.hooks.afterDelete(this.buildHookContext('delete', key, context)).catch(() => {}); // Don't fail on hook errors
}
}
@@ -197,15 +158,45 @@ export class RegistryStorage implements IStorageBackend {
// CONTEXT FOR HOOKS
// ========================================================================
- /**
- * Current operation context for hooks.
- * Set this before performing storage operations to enable hooks.
- */
- private currentContext?: {
- protocol: TRegistryProtocol;
- actor?: IStorageActor;
- metadata?: IStorageMetadata;
- };
+ private getCurrentContext(): TStorageOperationContext | undefined {
+ return this.contextStorage.getStore();
+ }
+
+ private mergeContext(
+ baseContext: TStorageOperationContext | undefined,
+ nextContext: TStorageOperationContext
+ ): TStorageOperationContext {
+ const actor = {
+ ...(baseContext?.actor ?? {}),
+ ...(nextContext.actor ?? {}),
+ };
+ const metadata = {
+ ...(baseContext?.metadata ?? {}),
+ ...(nextContext.metadata ?? {}),
+ };
+
+ return {
+ protocol: nextContext.protocol,
+ actor: Object.keys(actor).length > 0 ? actor : undefined,
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
+ };
+ }
+
+ private buildHookContext(
+ operation: IStorageHookContext['operation'],
+ key: string,
+ context: TStorageOperationContext,
+ metadata?: IStorageMetadata
+ ): IStorageHookContext {
+ return {
+ operation,
+ key,
+ protocol: context.protocol,
+ actor: context.actor,
+ metadata,
+ timestamp: new Date(),
+ };
+ }
/**
* Set the current operation context for hooks.
@@ -227,14 +218,14 @@ export class RegistryStorage implements IStorageBackend {
actor?: IStorageActor;
metadata?: IStorageMetadata;
}): void {
- this.currentContext = context;
+ this.contextStorage.enterWith(this.mergeContext(this.getCurrentContext(), context));
}
/**
* Clear the current operation context.
*/
public clearContext(): void {
- this.currentContext = undefined;
+ this.contextStorage.enterWith(undefined);
}
/**
@@ -249,12 +240,7 @@ export class RegistryStorage implements IStorageBackend {
},
fn: () => Promise
): Promise {
- this.setContext(context);
- try {
- return await fn();
- } finally {
- this.clearContext();
- }
+ return this.contextStorage.run(this.mergeContext(this.getCurrentContext(), context), fn);
}
/**
@@ -294,7 +280,7 @@ export class RegistryStorage implements IStorageBackend {
* Get OCI blob by digest
*/
public async getOciBlob(digest: string): Promise {
- const path = this.getOciBlobPath(digest);
+ const path = registryStoragePaths.getOciBlobPath(digest);
return this.getObject(path);
}
@@ -302,7 +288,7 @@ export class RegistryStorage implements IStorageBackend {
* Store OCI blob
*/
public async putOciBlob(digest: string, data: Buffer): Promise {
- const path = this.getOciBlobPath(digest);
+ const path = registryStoragePaths.getOciBlobPath(digest);
return this.putObject(path, data);
}
@@ -310,7 +296,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if OCI blob exists
*/
public async ociBlobExists(digest: string): Promise {
- const path = this.getOciBlobPath(digest);
+ const path = registryStoragePaths.getOciBlobPath(digest);
return this.objectExists(path);
}
@@ -318,7 +304,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete OCI blob
*/
public async deleteOciBlob(digest: string): Promise {
- const path = this.getOciBlobPath(digest);
+ const path = registryStoragePaths.getOciBlobPath(digest);
return this.deleteObject(path);
}
@@ -326,7 +312,7 @@ export class RegistryStorage implements IStorageBackend {
* Get OCI manifest and its content type
*/
public async getOciManifest(repository: string, digest: string): Promise {
- const path = this.getOciManifestPath(repository, digest);
+ const path = registryStoragePaths.getOciManifestPath(repository, digest);
return this.getObject(path);
}
@@ -335,7 +321,7 @@ export class RegistryStorage implements IStorageBackend {
* Returns the stored content type or null if not found
*/
public async getOciManifestContentType(repository: string, digest: string): Promise {
- const typePath = this.getOciManifestPath(repository, digest) + '.type';
+ const typePath = registryStoragePaths.getOciManifestPath(repository, digest) + '.type';
const data = await this.getObject(typePath);
return data ? data.toString('utf-8') : null;
}
@@ -349,7 +335,7 @@ export class RegistryStorage implements IStorageBackend {
data: Buffer,
contentType: string
): Promise {
- const path = this.getOciManifestPath(repository, digest);
+ const path = registryStoragePaths.getOciManifestPath(repository, digest);
// Store manifest data
await this.putObject(path, data, { 'Content-Type': contentType });
// Store content type in sidecar file for later retrieval
@@ -361,7 +347,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if OCI manifest exists
*/
public async ociManifestExists(repository: string, digest: string): Promise {
- const path = this.getOciManifestPath(repository, digest);
+ const path = registryStoragePaths.getOciManifestPath(repository, digest);
return this.objectExists(path);
}
@@ -369,7 +355,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete OCI manifest
*/
public async deleteOciManifest(repository: string, digest: string): Promise {
- const path = this.getOciManifestPath(repository, digest);
+ const path = registryStoragePaths.getOciManifestPath(repository, digest);
return this.deleteObject(path);
}
@@ -381,7 +367,7 @@ export class RegistryStorage implements IStorageBackend {
* Get NPM packument (package document)
*/
public async getNpmPackument(packageName: string): Promise {
- const path = this.getNpmPackumentPath(packageName);
+ const path = registryStoragePaths.getNpmPackumentPath(packageName);
const data = await this.getObject(path);
return data ? JSON.parse(data.toString('utf-8')) : null;
}
@@ -390,7 +376,7 @@ export class RegistryStorage implements IStorageBackend {
* Store NPM packument
*/
public async putNpmPackument(packageName: string, packument: any): Promise {
- const path = this.getNpmPackumentPath(packageName);
+ const path = registryStoragePaths.getNpmPackumentPath(packageName);
const data = Buffer.from(JSON.stringify(packument, null, 2), 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'application/json' });
}
@@ -399,7 +385,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if NPM packument exists
*/
public async npmPackumentExists(packageName: string): Promise {
- const path = this.getNpmPackumentPath(packageName);
+ const path = registryStoragePaths.getNpmPackumentPath(packageName);
return this.objectExists(path);
}
@@ -407,7 +393,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete NPM packument
*/
public async deleteNpmPackument(packageName: string): Promise {
- const path = this.getNpmPackumentPath(packageName);
+ const path = registryStoragePaths.getNpmPackumentPath(packageName);
return this.deleteObject(path);
}
@@ -415,7 +401,7 @@ export class RegistryStorage implements IStorageBackend {
* Get NPM tarball
*/
public async getNpmTarball(packageName: string, version: string): Promise {
- const path = this.getNpmTarballPath(packageName, version);
+ const path = registryStoragePaths.getNpmTarballPath(packageName, version);
return this.getObject(path);
}
@@ -427,7 +413,7 @@ export class RegistryStorage implements IStorageBackend {
version: string,
tarball: Buffer
): Promise {
- const path = this.getNpmTarballPath(packageName, version);
+ const path = registryStoragePaths.getNpmTarballPath(packageName, version);
return this.putObject(path, tarball, { 'Content-Type': 'application/octet-stream' });
}
@@ -435,7 +421,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if NPM tarball exists
*/
public async npmTarballExists(packageName: string, version: string): Promise {
- const path = this.getNpmTarballPath(packageName, version);
+ const path = registryStoragePaths.getNpmTarballPath(packageName, version);
return this.objectExists(path);
}
@@ -443,33 +429,10 @@ export class RegistryStorage implements IStorageBackend {
* Delete NPM tarball
*/
public async deleteNpmTarball(packageName: string, version: string): Promise {
- const path = this.getNpmTarballPath(packageName, version);
+ const path = registryStoragePaths.getNpmTarballPath(packageName, version);
return this.deleteObject(path);
}
- // ========================================================================
- // PATH HELPERS
- // ========================================================================
-
- private getOciBlobPath(digest: string): string {
- const hash = digest.split(':')[1];
- return `oci/blobs/sha256/${hash}`;
- }
-
- private getOciManifestPath(repository: string, digest: string): string {
- const hash = digest.split(':')[1];
- return `oci/manifests/${repository}/${hash}`;
- }
-
- private getNpmPackumentPath(packageName: string): string {
- return `npm/packages/${packageName}/index.json`;
- }
-
- private getNpmTarballPath(packageName: string, version: string): string {
- const safeName = packageName.replace('@', '').replace('/', '-');
- return `npm/packages/${packageName}/${safeName}-${version}.tgz`;
- }
-
// ========================================================================
// MAVEN STORAGE METHODS
// ========================================================================
@@ -483,7 +446,7 @@ export class RegistryStorage implements IStorageBackend {
version: string,
filename: string
): Promise {
- const path = this.getMavenArtifactPath(groupId, artifactId, version, filename);
+ const path = registryStoragePaths.getMavenArtifactPath(groupId, artifactId, version, filename);
return this.getObject(path);
}
@@ -497,7 +460,7 @@ export class RegistryStorage implements IStorageBackend {
filename: string,
data: Buffer
): Promise {
- const path = this.getMavenArtifactPath(groupId, artifactId, version, filename);
+ const path = registryStoragePaths.getMavenArtifactPath(groupId, artifactId, version, filename);
return this.putObject(path, data);
}
@@ -510,7 +473,7 @@ export class RegistryStorage implements IStorageBackend {
version: string,
filename: string
): Promise {
- const path = this.getMavenArtifactPath(groupId, artifactId, version, filename);
+ const path = registryStoragePaths.getMavenArtifactPath(groupId, artifactId, version, filename);
return this.objectExists(path);
}
@@ -523,7 +486,7 @@ export class RegistryStorage implements IStorageBackend {
version: string,
filename: string
): Promise {
- const path = this.getMavenArtifactPath(groupId, artifactId, version, filename);
+ const path = registryStoragePaths.getMavenArtifactPath(groupId, artifactId, version, filename);
return this.deleteObject(path);
}
@@ -534,7 +497,7 @@ export class RegistryStorage implements IStorageBackend {
groupId: string,
artifactId: string
): Promise {
- const path = this.getMavenMetadataPath(groupId, artifactId);
+ const path = registryStoragePaths.getMavenMetadataPath(groupId, artifactId);
return this.getObject(path);
}
@@ -546,7 +509,7 @@ export class RegistryStorage implements IStorageBackend {
artifactId: string,
data: Buffer
): Promise {
- const path = this.getMavenMetadataPath(groupId, artifactId);
+ const path = registryStoragePaths.getMavenMetadataPath(groupId, artifactId);
return this.putObject(path, data);
}
@@ -557,7 +520,7 @@ export class RegistryStorage implements IStorageBackend {
groupId: string,
artifactId: string
): Promise {
- const path = this.getMavenMetadataPath(groupId, artifactId);
+ const path = registryStoragePaths.getMavenMetadataPath(groupId, artifactId);
return this.deleteObject(path);
}
@@ -587,25 +550,6 @@ export class RegistryStorage implements IStorageBackend {
return Array.from(versions).sort();
}
- // ========================================================================
- // MAVEN PATH HELPERS
- // ========================================================================
-
- private getMavenArtifactPath(
- groupId: string,
- artifactId: string,
- version: string,
- filename: string
- ): string {
- const groupPath = groupId.replace(/\./g, '/');
- return `maven/artifacts/${groupPath}/${artifactId}/${version}/${filename}`;
- }
-
- private getMavenMetadataPath(groupId: string, artifactId: string): string {
- const groupPath = groupId.replace(/\./g, '/');
- return `maven/metadata/${groupPath}/${artifactId}/maven-metadata.xml`;
- }
-
// ========================================================================
// CARGO-SPECIFIC HELPERS
// ========================================================================
@@ -614,7 +558,7 @@ export class RegistryStorage implements IStorageBackend {
* Get Cargo config.json
*/
public async getCargoConfig(): Promise {
- const data = await this.getObject('cargo/config.json');
+ const data = await this.getObject(registryStoragePaths.getCargoConfigPath());
return data ? JSON.parse(data.toString('utf-8')) : null;
}
@@ -623,14 +567,14 @@ export class RegistryStorage implements IStorageBackend {
*/
public async putCargoConfig(config: any): Promise {
const data = Buffer.from(JSON.stringify(config, null, 2), 'utf-8');
- return this.putObject('cargo/config.json', data, { 'Content-Type': 'application/json' });
+ return this.putObject(registryStoragePaths.getCargoConfigPath(), data, { 'Content-Type': 'application/json' });
}
/**
* Get Cargo index file (newline-delimited JSON)
*/
public async getCargoIndex(crateName: string): Promise {
- const path = this.getCargoIndexPath(crateName);
+ const path = registryStoragePaths.getCargoIndexPath(crateName);
const data = await this.getObject(path);
if (!data) return null;
@@ -643,7 +587,7 @@ export class RegistryStorage implements IStorageBackend {
* Store Cargo index file
*/
public async putCargoIndex(crateName: string, entries: any[]): Promise {
- const path = this.getCargoIndexPath(crateName);
+ const path = registryStoragePaths.getCargoIndexPath(crateName);
// Convert to newline-delimited JSON
const data = Buffer.from(entries.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/plain' });
@@ -653,7 +597,7 @@ export class RegistryStorage implements IStorageBackend {
* Get Cargo .crate file
*/
public async getCargoCrate(crateName: string, version: string): Promise {
- const path = this.getCargoCratePath(crateName, version);
+ const path = registryStoragePaths.getCargoCratePath(crateName, version);
return this.getObject(path);
}
@@ -665,7 +609,7 @@ export class RegistryStorage implements IStorageBackend {
version: string,
crateFile: Buffer
): Promise {
- const path = this.getCargoCratePath(crateName, version);
+ const path = registryStoragePaths.getCargoCratePath(crateName, version);
return this.putObject(path, crateFile, { 'Content-Type': 'application/gzip' });
}
@@ -673,7 +617,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if Cargo crate exists
*/
public async cargoCrateExists(crateName: string, version: string): Promise {
- const path = this.getCargoCratePath(crateName, version);
+ const path = registryStoragePaths.getCargoCratePath(crateName, version);
return this.objectExists(path);
}
@@ -681,36 +625,10 @@ export class RegistryStorage implements IStorageBackend {
* Delete Cargo crate (for cleanup, not for unpublishing)
*/
public async deleteCargoCrate(crateName: string, version: string): Promise {
- const path = this.getCargoCratePath(crateName, version);
+ const path = registryStoragePaths.getCargoCratePath(crateName, version);
return this.deleteObject(path);
}
- // ========================================================================
- // CARGO PATH HELPERS
- // ========================================================================
-
- private getCargoIndexPath(crateName: string): string {
- const lower = crateName.toLowerCase();
- const len = lower.length;
-
- if (len === 1) {
- return `cargo/index/1/${lower}`;
- } else if (len === 2) {
- return `cargo/index/2/${lower}`;
- } else if (len === 3) {
- return `cargo/index/3/${lower.charAt(0)}/${lower}`;
- } else {
- // 4+ characters: {first-two}/{second-two}/{name}
- const prefix1 = lower.substring(0, 2);
- const prefix2 = lower.substring(2, 4);
- return `cargo/index/${prefix1}/${prefix2}/${lower}`;
- }
- }
-
- private getCargoCratePath(crateName: string, version: string): string {
- return `cargo/crates/${crateName}/${crateName}-${version}.crate`;
- }
-
// ========================================================================
// COMPOSER-SPECIFIC HELPERS
// ========================================================================
@@ -719,7 +637,7 @@ export class RegistryStorage implements IStorageBackend {
* Get Composer package metadata
*/
public async getComposerPackageMetadata(vendorPackage: string): Promise {
- const path = this.getComposerMetadataPath(vendorPackage);
+ const path = registryStoragePaths.getComposerMetadataPath(vendorPackage);
const data = await this.getObject(path);
return data ? JSON.parse(data.toString('utf-8')) : null;
}
@@ -728,7 +646,7 @@ export class RegistryStorage implements IStorageBackend {
* Store Composer package metadata
*/
public async putComposerPackageMetadata(vendorPackage: string, metadata: any): Promise {
- const path = this.getComposerMetadataPath(vendorPackage);
+ const path = registryStoragePaths.getComposerMetadataPath(vendorPackage);
const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'application/json' });
}
@@ -737,7 +655,7 @@ export class RegistryStorage implements IStorageBackend {
* Get Composer package ZIP
*/
public async getComposerPackageZip(vendorPackage: string, reference: string): Promise {
- const path = this.getComposerZipPath(vendorPackage, reference);
+ const path = registryStoragePaths.getComposerZipPath(vendorPackage, reference);
return this.getObject(path);
}
@@ -745,7 +663,7 @@ export class RegistryStorage implements IStorageBackend {
* Store Composer package ZIP
*/
public async putComposerPackageZip(vendorPackage: string, reference: string, zipData: Buffer): Promise {
- const path = this.getComposerZipPath(vendorPackage, reference);
+ const path = registryStoragePaths.getComposerZipPath(vendorPackage, reference);
return this.putObject(path, zipData, { 'Content-Type': 'application/zip' });
}
@@ -753,7 +671,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if Composer package metadata exists
*/
public async composerPackageMetadataExists(vendorPackage: string): Promise {
- const path = this.getComposerMetadataPath(vendorPackage);
+ const path = registryStoragePaths.getComposerMetadataPath(vendorPackage);
return this.objectExists(path);
}
@@ -761,7 +679,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete Composer package metadata
*/
public async deleteComposerPackageMetadata(vendorPackage: string): Promise {
- const path = this.getComposerMetadataPath(vendorPackage);
+ const path = registryStoragePaths.getComposerMetadataPath(vendorPackage);
return this.deleteObject(path);
}
@@ -769,7 +687,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete Composer package ZIP
*/
public async deleteComposerPackageZip(vendorPackage: string, reference: string): Promise {
- const path = this.getComposerZipPath(vendorPackage, reference);
+ const path = registryStoragePaths.getComposerZipPath(vendorPackage, reference);
return this.deleteObject(path);
}
@@ -796,14 +714,6 @@ export class RegistryStorage implements IStorageBackend {
// COMPOSER PATH HELPERS
// ========================================================================
- private getComposerMetadataPath(vendorPackage: string): string {
- return `composer/packages/${vendorPackage}/metadata.json`;
- }
-
- private getComposerZipPath(vendorPackage: string, reference: string): string {
- return `composer/packages/${vendorPackage}/${reference}.zip`;
- }
-
// ========================================================================
// PYPI STORAGE METHODS
// ========================================================================
@@ -812,7 +722,7 @@ export class RegistryStorage implements IStorageBackend {
* Get PyPI package metadata
*/
public async getPypiPackageMetadata(packageName: string): Promise {
- const path = this.getPypiMetadataPath(packageName);
+ const path = registryStoragePaths.getPypiMetadataPath(packageName);
const data = await this.getObject(path);
return data ? JSON.parse(data.toString('utf-8')) : null;
}
@@ -821,7 +731,7 @@ export class RegistryStorage implements IStorageBackend {
* Store PyPI package metadata
*/
public async putPypiPackageMetadata(packageName: string, metadata: any): Promise {
- const path = this.getPypiMetadataPath(packageName);
+ const path = registryStoragePaths.getPypiMetadataPath(packageName);
const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'application/json' });
}
@@ -830,7 +740,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if PyPI package metadata exists
*/
public async pypiPackageMetadataExists(packageName: string): Promise {
- const path = this.getPypiMetadataPath(packageName);
+ const path = registryStoragePaths.getPypiMetadataPath(packageName);
return this.objectExists(path);
}
@@ -838,7 +748,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete PyPI package metadata
*/
public async deletePypiPackageMetadata(packageName: string): Promise {
- const path = this.getPypiMetadataPath(packageName);
+ const path = registryStoragePaths.getPypiMetadataPath(packageName);
return this.deleteObject(path);
}
@@ -846,7 +756,7 @@ export class RegistryStorage implements IStorageBackend {
* Get PyPI Simple API index (HTML)
*/
public async getPypiSimpleIndex(packageName: string): Promise {
- const path = this.getPypiSimpleIndexPath(packageName);
+ const path = registryStoragePaths.getPypiSimpleIndexPath(packageName);
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
@@ -855,7 +765,7 @@ export class RegistryStorage implements IStorageBackend {
* Store PyPI Simple API index (HTML)
*/
public async putPypiSimpleIndex(packageName: string, html: string): Promise {
- const path = this.getPypiSimpleIndexPath(packageName);
+ const path = registryStoragePaths.getPypiSimpleIndexPath(packageName);
const data = Buffer.from(html, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' });
}
@@ -864,7 +774,7 @@ export class RegistryStorage implements IStorageBackend {
* Get PyPI root Simple API index (HTML)
*/
public async getPypiSimpleRootIndex(): Promise {
- const path = this.getPypiSimpleRootIndexPath();
+ const path = registryStoragePaths.getPypiSimpleRootIndexPath();
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
@@ -873,7 +783,7 @@ export class RegistryStorage implements IStorageBackend {
* Store PyPI root Simple API index (HTML)
*/
public async putPypiSimpleRootIndex(html: string): Promise {
- const path = this.getPypiSimpleRootIndexPath();
+ const path = registryStoragePaths.getPypiSimpleRootIndexPath();
const data = Buffer.from(html, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' });
}
@@ -882,7 +792,7 @@ export class RegistryStorage implements IStorageBackend {
* Get PyPI package file (wheel, sdist)
*/
public async getPypiPackageFile(packageName: string, filename: string): Promise {
- const path = this.getPypiPackageFilePath(packageName, filename);
+ const path = registryStoragePaths.getPypiPackageFilePath(packageName, filename);
return this.getObject(path);
}
@@ -894,7 +804,7 @@ export class RegistryStorage implements IStorageBackend {
filename: string,
data: Buffer
): Promise {
- const path = this.getPypiPackageFilePath(packageName, filename);
+ const path = registryStoragePaths.getPypiPackageFilePath(packageName, filename);
return this.putObject(path, data, { 'Content-Type': 'application/octet-stream' });
}
@@ -902,7 +812,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if PyPI package file exists
*/
public async pypiPackageFileExists(packageName: string, filename: string): Promise {
- const path = this.getPypiPackageFilePath(packageName, filename);
+ const path = registryStoragePaths.getPypiPackageFilePath(packageName, filename);
return this.objectExists(path);
}
@@ -910,7 +820,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete PyPI package file
*/
public async deletePypiPackageFile(packageName: string, filename: string): Promise {
- const path = this.getPypiPackageFilePath(packageName, filename);
+ const path = registryStoragePaths.getPypiPackageFilePath(packageName, filename);
return this.deleteObject(path);
}
@@ -966,7 +876,7 @@ export class RegistryStorage implements IStorageBackend {
await this.deletePypiPackageMetadata(packageName);
// Delete Simple API index
- const simpleIndexPath = this.getPypiSimpleIndexPath(packageName);
+ const simpleIndexPath = registryStoragePaths.getPypiSimpleIndexPath(packageName);
try {
await this.deleteObject(simpleIndexPath);
} catch (error) {
@@ -1015,22 +925,6 @@ export class RegistryStorage implements IStorageBackend {
// PYPI PATH HELPERS
// ========================================================================
- private getPypiMetadataPath(packageName: string): string {
- return `pypi/metadata/${packageName}/metadata.json`;
- }
-
- private getPypiSimpleIndexPath(packageName: string): string {
- return `pypi/simple/${packageName}/index.html`;
- }
-
- private getPypiSimpleRootIndexPath(): string {
- return `pypi/simple/index.html`;
- }
-
- private getPypiPackageFilePath(packageName: string, filename: string): string {
- return `pypi/packages/${packageName}/${filename}`;
- }
-
// ========================================================================
// RUBYGEMS STORAGE METHODS
// ========================================================================
@@ -1039,7 +933,7 @@ export class RegistryStorage implements IStorageBackend {
* Get RubyGems versions file (compact index)
*/
public async getRubyGemsVersions(): Promise {
- const path = this.getRubyGemsVersionsPath();
+ const path = registryStoragePaths.getRubyGemsVersionsPath();
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
@@ -1048,7 +942,7 @@ export class RegistryStorage implements IStorageBackend {
* Store RubyGems versions file (compact index)
*/
public async putRubyGemsVersions(content: string): Promise {
- const path = this.getRubyGemsVersionsPath();
+ const path = registryStoragePaths.getRubyGemsVersionsPath();
const data = Buffer.from(content, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/plain; charset=utf-8' });
}
@@ -1057,7 +951,7 @@ export class RegistryStorage implements IStorageBackend {
* Get RubyGems info file for a gem (compact index)
*/
public async getRubyGemsInfo(gemName: string): Promise {
- const path = this.getRubyGemsInfoPath(gemName);
+ const path = registryStoragePaths.getRubyGemsInfoPath(gemName);
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
@@ -1066,7 +960,7 @@ export class RegistryStorage implements IStorageBackend {
* Store RubyGems info file for a gem (compact index)
*/
public async putRubyGemsInfo(gemName: string, content: string): Promise {
- const path = this.getRubyGemsInfoPath(gemName);
+ const path = registryStoragePaths.getRubyGemsInfoPath(gemName);
const data = Buffer.from(content, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/plain; charset=utf-8' });
}
@@ -1075,7 +969,7 @@ export class RegistryStorage implements IStorageBackend {
* Get RubyGems names file
*/
public async getRubyGemsNames(): Promise {
- const path = this.getRubyGemsNamesPath();
+ const path = registryStoragePaths.getRubyGemsNamesPath();
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
@@ -1084,7 +978,7 @@ export class RegistryStorage implements IStorageBackend {
* Store RubyGems names file
*/
public async putRubyGemsNames(content: string): Promise {
- const path = this.getRubyGemsNamesPath();
+ const path = registryStoragePaths.getRubyGemsNamesPath();
const data = Buffer.from(content, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/plain; charset=utf-8' });
}
@@ -1093,7 +987,7 @@ export class RegistryStorage implements IStorageBackend {
* Get RubyGems .gem file
*/
public async getRubyGemsGem(gemName: string, version: string, platform?: string): Promise {
- const path = this.getRubyGemsGemPath(gemName, version, platform);
+ const path = registryStoragePaths.getRubyGemsGemPath(gemName, version, platform);
return this.getObject(path);
}
@@ -1106,7 +1000,7 @@ export class RegistryStorage implements IStorageBackend {
data: Buffer,
platform?: string
): Promise {
- const path = this.getRubyGemsGemPath(gemName, version, platform);
+ const path = registryStoragePaths.getRubyGemsGemPath(gemName, version, platform);
return this.putObject(path, data, { 'Content-Type': 'application/octet-stream' });
}
@@ -1114,7 +1008,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if RubyGems .gem file exists
*/
public async rubyGemsGemExists(gemName: string, version: string, platform?: string): Promise {
- const path = this.getRubyGemsGemPath(gemName, version, platform);
+ const path = registryStoragePaths.getRubyGemsGemPath(gemName, version, platform);
return this.objectExists(path);
}
@@ -1122,7 +1016,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete RubyGems .gem file
*/
public async deleteRubyGemsGem(gemName: string, version: string, platform?: string): Promise {
- const path = this.getRubyGemsGemPath(gemName, version, platform);
+ const path = registryStoragePaths.getRubyGemsGemPath(gemName, version, platform);
return this.deleteObject(path);
}
@@ -1130,7 +1024,7 @@ export class RegistryStorage implements IStorageBackend {
* Get RubyGems metadata
*/
public async getRubyGemsMetadata(gemName: string): Promise {
- const path = this.getRubyGemsMetadataPath(gemName);
+ const path = registryStoragePaths.getRubyGemsMetadataPath(gemName);
const data = await this.getObject(path);
return data ? JSON.parse(data.toString('utf-8')) : null;
}
@@ -1139,7 +1033,7 @@ export class RegistryStorage implements IStorageBackend {
* Store RubyGems metadata
*/
public async putRubyGemsMetadata(gemName: string, metadata: any): Promise {
- const path = this.getRubyGemsMetadataPath(gemName);
+ const path = registryStoragePaths.getRubyGemsMetadataPath(gemName);
const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'application/json' });
}
@@ -1148,7 +1042,7 @@ export class RegistryStorage implements IStorageBackend {
* Check if RubyGems metadata exists
*/
public async rubyGemsMetadataExists(gemName: string): Promise {
- const path = this.getRubyGemsMetadataPath(gemName);
+ const path = registryStoragePaths.getRubyGemsMetadataPath(gemName);
return this.objectExists(path);
}
@@ -1156,7 +1050,7 @@ export class RegistryStorage implements IStorageBackend {
* Delete RubyGems metadata
*/
public async deleteRubyGemsMetadata(gemName: string): Promise {
- const path = this.getRubyGemsMetadataPath(gemName);
+ const path = registryStoragePaths.getRubyGemsMetadataPath(gemName);
return this.deleteObject(path);
}
@@ -1242,31 +1136,6 @@ export class RegistryStorage implements IStorageBackend {
}
}
- // ========================================================================
- // RUBYGEMS PATH HELPERS
- // ========================================================================
-
- private getRubyGemsVersionsPath(): string {
- return 'rubygems/versions';
- }
-
- private getRubyGemsInfoPath(gemName: string): string {
- return `rubygems/info/${gemName}`;
- }
-
- private getRubyGemsNamesPath(): string {
- return 'rubygems/names';
- }
-
- private getRubyGemsGemPath(gemName: string, version: string, platform?: string): string {
- const filename = platform ? `${gemName}-${version}-${platform}.gem` : `${gemName}-${version}.gem`;
- return `rubygems/gems/${filename}`;
- }
-
- private getRubyGemsMetadataPath(gemName: string): string {
- return `rubygems/metadata/${gemName}/metadata.json`;
- }
-
// ========================================================================
// STREAMING METHODS (Web Streams API)
// ========================================================================
@@ -1275,24 +1144,16 @@ export class RegistryStorage implements IStorageBackend {
* Get an object as a ReadableStream. Returns null if not found.
*/
public async getObjectStream(key: string): Promise<{ stream: ReadableStream; size: number } | null> {
+ const context = this.getCurrentContext();
+
try {
const stat = await this.bucket.fastStat({ path: key });
const size = stat.ContentLength ?? 0;
const stream = await this.bucket.fastGetStream({ path: key }, 'webstream');
// Call afterGet hook (non-blocking)
- if (this.hooks?.afterGet) {
- const context = this.currentContext;
- if (context) {
- this.hooks.afterGet({
- operation: 'get',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: context.metadata,
- timestamp: new Date(),
- }).catch(() => {});
- }
+ if (this.hooks?.afterGet && context) {
+ this.hooks.afterGet(this.buildHookContext('get', key, context)).catch(() => {});
}
return { stream: stream as ReadableStream, size };
@@ -1305,21 +1166,16 @@ export class RegistryStorage implements IStorageBackend {
* Store an object from a ReadableStream.
*/
public async putObjectStream(key: string, stream: ReadableStream): Promise {
- if (this.hooks?.beforePut) {
- const context = this.currentContext;
- if (context) {
- const hookContext: IStorageHookContext = {
- operation: 'put',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: context.metadata,
- timestamp: new Date(),
- };
- const result = await this.hooks.beforePut(hookContext);
- if (!result.allowed) {
- throw new Error(result.reason || 'Storage operation denied by hook');
- }
+ const context = this.getCurrentContext();
+ let hookMetadata = context?.metadata;
+
+ if (this.hooks?.beforePut && context) {
+ const result = await this.hooks.beforePut(this.buildHookContext('put', key, context, hookMetadata));
+ if (!result.allowed) {
+ throw new Error(result.reason || 'Storage operation denied by hook');
+ }
+ if (result.metadata) {
+ hookMetadata = { ...(hookMetadata ?? {}), ...result.metadata };
}
}
@@ -1333,18 +1189,8 @@ export class RegistryStorage implements IStorageBackend {
overwrite: true,
});
- if (this.hooks?.afterPut) {
- const context = this.currentContext;
- if (context) {
- this.hooks.afterPut({
- operation: 'put',
- key,
- protocol: context.protocol,
- actor: context.actor,
- metadata: context.metadata,
- timestamp: new Date(),
- }).catch(() => {});
- }
+ if (this.hooks?.afterPut && context) {
+ this.hooks.afterPut(this.buildHookContext('put', key, context, hookMetadata)).catch(() => {});
}
}
@@ -1363,38 +1209,38 @@ export class RegistryStorage implements IStorageBackend {
// ---- Protocol-specific streaming wrappers ----
public async getOciBlobStream(digest: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getOciBlobPath(digest));
+ return this.getObjectStream(registryStoragePaths.getOciBlobPath(digest));
}
public async putOciBlobStream(digest: string, stream: ReadableStream): Promise {
- return this.putObjectStream(this.getOciBlobPath(digest), stream);
+ return this.putObjectStream(registryStoragePaths.getOciBlobPath(digest), stream);
}
public async getOciBlobSize(digest: string): Promise {
- return this.getObjectSize(this.getOciBlobPath(digest));
+ return this.getObjectSize(registryStoragePaths.getOciBlobPath(digest));
}
public async getNpmTarballStream(packageName: string, version: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getNpmTarballPath(packageName, version));
+ return this.getObjectStream(registryStoragePaths.getNpmTarballPath(packageName, version));
}
public async getMavenArtifactStream(groupId: string, artifactId: string, version: string, filename: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getMavenArtifactPath(groupId, artifactId, version, filename));
+ return this.getObjectStream(registryStoragePaths.getMavenArtifactPath(groupId, artifactId, version, filename));
}
public async getCargoCrateStream(crateName: string, version: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getCargoCratePath(crateName, version));
+ return this.getObjectStream(registryStoragePaths.getCargoCratePath(crateName, version));
}
public async getComposerPackageZipStream(vendorPackage: string, reference: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getComposerZipPath(vendorPackage, reference));
+ return this.getObjectStream(registryStoragePaths.getComposerZipPath(vendorPackage, reference));
}
public async getPypiPackageFileStream(packageName: string, filename: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getPypiPackageFilePath(packageName, filename));
+ return this.getObjectStream(registryStoragePaths.getPypiPackageFilePath(packageName, filename));
}
public async getRubyGemsGemStream(gemName: string, version: string, platform?: string): Promise<{ stream: ReadableStream; size: number } | null> {
- return this.getObjectStream(this.getRubyGemsGemPath(gemName, version, platform));
+ return this.getObjectStream(registryStoragePaths.getRubyGemsGemPath(gemName, version, platform));
}
}
diff --git a/ts/core/helpers.registrystoragepaths.ts b/ts/core/helpers.registrystoragepaths.ts
new file mode 100644
index 0000000..e5d9d6d
--- /dev/null
+++ b/ts/core/helpers.registrystoragepaths.ts
@@ -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`;
+}
diff --git a/ts/maven/classes.mavenregistry.ts b/ts/maven/classes.mavenregistry.ts
index 74a7d6d..b8dc07b 100644
--- a/ts/maven/classes.mavenregistry.ts
+++ b/ts/maven/classes.mavenregistry.ts
@@ -105,56 +105,48 @@ export class MavenRegistry extends BaseRegistry {
// Remove base path from URL
const path = context.path.replace(this.basePath, '');
- // Extract token from Authorization header
- const authHeader = context.headers['authorization'] || context.headers['Authorization'];
+ const authHeader = this.getAuthorizationHeader(context);
let token: IAuthToken | null = null;
if (authHeader) {
- if (/^Basic\s+/i.test(authHeader)) {
+ const basicCredentials = this.parseBasicAuthHeader(authHeader);
+ if (basicCredentials) {
// Maven sends Basic Auth: base64(username:password) — extract the password as token
- const base64 = authHeader.replace(/^Basic\s+/i, '');
- const decoded = Buffer.from(base64, 'base64').toString('utf-8');
- const colonIndex = decoded.indexOf(':');
- const password = colonIndex >= 0 ? decoded.substring(colonIndex + 1) : decoded;
- token = await this.authManager.validateToken(password, 'maven');
+ token = await this.authManager.validateToken(basicCredentials.password, 'maven');
} else {
- const tokenString = authHeader.replace(/^Bearer\s+/i, '');
- token = await this.authManager.validateToken(tokenString, 'maven');
+ const tokenString = this.extractBearerToken(authHeader);
+ token = tokenString ? await this.authManager.validateToken(tokenString, 'maven') : null;
}
}
- // Build actor from context and validated token
- const actor: IRequestActor = {
- ...context.actor,
- userId: token?.userId,
- ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
- userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
- };
+ const actor: IRequestActor = this.buildRequestActor(context, token);
- // Parse path to determine request type
- const coordinate = pathToGAV(path);
+ return this.storage.withContext({ protocol: 'maven', actor }, async () => {
+ // Parse path to determine request type
+ const coordinate = pathToGAV(path);
- if (!coordinate) {
- // Not a valid artifact path, could be metadata or root
- if (path.endsWith('/maven-metadata.xml')) {
- return this.handleMetadataRequest(context.method, path, token, actor);
+ if (!coordinate) {
+ // Not a valid artifact path, could be metadata or root
+ if (path.endsWith('/maven-metadata.xml')) {
+ return this.handleMetadataRequest(context.method, path, token, actor);
+ }
+
+ return {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ body: { error: 'NOT_FOUND', message: 'Invalid Maven path' },
+ };
}
- return {
- status: 404,
- headers: { 'Content-Type': 'application/json' },
- body: { error: 'NOT_FOUND', message: 'Invalid Maven path' },
- };
- }
+ // Check if it's a checksum file
+ if (coordinate.extension === 'md5' || coordinate.extension === 'sha1' ||
+ coordinate.extension === 'sha256' || coordinate.extension === 'sha512') {
+ return this.handleChecksumRequest(context.method, coordinate, token, path);
+ }
- // Check if it's a checksum file
- if (coordinate.extension === 'md5' || coordinate.extension === 'sha1' ||
- coordinate.extension === 'sha256' || coordinate.extension === 'sha512') {
- return this.handleChecksumRequest(context.method, coordinate, token, path);
- }
-
- // Handle artifact requests (JAR, POM, WAR, etc.)
- return this.handleArtifactRequest(context.method, coordinate, token, context.body, actor);
+ // Handle artifact requests (JAR, POM, WAR, etc.)
+ return this.handleArtifactRequest(context.method, coordinate, token, context.body, actor);
+ });
}
protected async checkPermission(
diff --git a/ts/npm/classes.npmregistry.ts b/ts/npm/classes.npmregistry.ts
index 06bdb27..89ca5a9 100644
--- a/ts/npm/classes.npmregistry.ts
+++ b/ts/npm/classes.npmregistry.ts
@@ -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 {
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,
actor?: IRequestActor
): Promise {
- 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 {
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 => {
+ // 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(
+ packageName: string,
+ actor: IRequestActor | undefined,
+ fn: () => Promise
+ ): Promise {
+ return this.storage.withContext(
+ { protocol: 'npm', actor, metadata: { packageName } },
+ fn
+ );
+ }
+
+ private async getLocalOrUpstreamPackument(
+ packageName: string,
+ actor: IRequestActor | undefined,
+ logPrefix: string
+ ): Promise {
+ 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(
+ packageName: string,
+ version: string,
+ actor: IRequestActor | undefined,
+ fn: () => Promise
+ ): Promise {
+ return this.storage.withContext(
+ { protocol: 'npm', actor, metadata: { packageName, version } },
+ fn
+ );
}
private async handleSearch(query: Record): Promise {
diff --git a/ts/npm/helpers.npmpublish.ts b/ts/npm/helpers.npmpublish.ts
new file mode 100644
index 0000000..10848d0
--- /dev/null
+++ b/ts/npm/helpers.npmpublish.ts
@@ -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;
+ }
+}
diff --git a/ts/npm/helpers.npmroutes.ts b/ts/npm/helpers.npmroutes.ts
new file mode 100644
index 0000000..256c1b0
--- /dev/null
+++ b/ts/npm/helpers.npmroutes.ts
@@ -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;
+}
diff --git a/ts/oci/classes.ociregistry.ts b/ts/oci/classes.ociregistry.ts
index 8eba511..c73059d 100644
--- a/ts/oci/classes.ociregistry.ts
+++ b/ts/oci/classes.ociregistry.ts
@@ -42,18 +42,7 @@ export class OciRegistry extends BaseRegistry {
this.ociTokens = ociTokens;
this.upstreamProvider = upstreamProvider || null;
- // Initialize logger
- this.logger = new Smartlog({
- logContext: {
- company: 'push.rocks',
- companyunit: 'smartregistry',
- containerName: 'oci-registry',
- environment: (process.env.NODE_ENV as any) || 'development',
- runtime: 'node',
- zone: 'oci'
- }
- });
- this.logger.enableConsole();
+ this.logger = this.createProtocolLogger('oci-registry', 'oci');
if (upstreamProvider) {
this.logger.log('info', 'OCI upstream provider configured');
@@ -112,76 +101,70 @@ export class OciRegistry extends BaseRegistry {
// Remove base path from URL
const path = context.path.replace(this.basePath, '');
- // Extract token from Authorization header
- const authHeader = context.headers['authorization'] || context.headers['Authorization'];
- const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
+ const tokenString = this.extractBearerToken(context);
const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
- // Build actor from context and validated token
- const actor: IRequestActor = {
- ...context.actor,
- userId: token?.userId,
- ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
- userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
- };
+ const actor: IRequestActor = this.buildRequestActor(context, token);
- // Route to appropriate handler
- // OCI spec: GET /v2/ is the version check endpoint
- if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
- return this.handleVersionCheck();
- }
+ return this.storage.withContext({ protocol: 'oci', actor }, async () => {
+ // Route to appropriate handler
+ // OCI spec: GET /v2/ is the version check endpoint
+ if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
+ return this.handleVersionCheck();
+ }
- // Manifest operations: /{name}/manifests/{reference}
- const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
- if (manifestMatch) {
- const [, name, reference] = manifestMatch;
- // Prefer rawBody for content-addressable operations to preserve exact bytes
- const bodyData = context.rawBody || context.body;
- return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
- }
+ // Manifest operations: /{name}/manifests/{reference}
+ const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
+ if (manifestMatch) {
+ const [, name, reference] = manifestMatch;
+ // Prefer rawBody for content-addressable operations to preserve exact bytes
+ const bodyData = context.rawBody || context.body;
+ return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
+ }
- // Blob operations: /{name}/blobs/{digest}
- const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
- if (blobMatch) {
- const [, name, digest] = blobMatch;
- return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
- }
+ // Blob operations: /{name}/blobs/{digest}
+ const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
+ if (blobMatch) {
+ const [, name, digest] = blobMatch;
+ return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
+ }
- // Blob upload operations: /{name}/blobs/uploads/
- const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
- if (uploadInitMatch && context.method === 'POST') {
- const [, name] = uploadInitMatch;
- // Prefer rawBody for content-addressable operations to preserve exact bytes
- const bodyData = context.rawBody || context.body;
- return this.handleUploadInit(name, token, context.query, bodyData);
- }
+ // Blob upload operations: /{name}/blobs/uploads/
+ const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
+ if (uploadInitMatch && context.method === 'POST') {
+ const [, name] = uploadInitMatch;
+ // Prefer rawBody for content-addressable operations to preserve exact bytes
+ const bodyData = context.rawBody || context.body;
+ return this.handleUploadInit(name, token, context.query, bodyData);
+ }
- // Blob upload operations: /{name}/blobs/uploads/{uuid}
- const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
- if (uploadMatch) {
- const [, name, uploadId] = uploadMatch;
- return this.handleUploadSession(context.method, uploadId, token, context);
- }
+ // Blob upload operations: /{name}/blobs/uploads/{uuid}
+ const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
+ if (uploadMatch) {
+ const [, name, uploadId] = uploadMatch;
+ return this.handleUploadSession(context.method, uploadId, token, context);
+ }
- // Tags list: /{name}/tags/list
- const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
- if (tagsMatch) {
- const [, name] = tagsMatch;
- return this.handleTagsList(name, token, context.query);
- }
+ // Tags list: /{name}/tags/list
+ const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
+ if (tagsMatch) {
+ const [, name] = tagsMatch;
+ return this.handleTagsList(name, token, context.query);
+ }
- // Referrers: /{name}/referrers/{digest}
- const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
- if (referrersMatch) {
- const [, name, digest] = referrersMatch;
- return this.handleReferrers(name, digest, token, context.query);
- }
+ // Referrers: /{name}/referrers/{digest}
+ const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
+ if (referrersMatch) {
+ const [, name, digest] = referrersMatch;
+ return this.handleReferrers(name, digest, token, context.query);
+ }
- return {
- status: 404,
- headers: { 'Content-Type': 'application/json' },
- body: this.createError('NOT_FOUND', 'Endpoint not found'),
- };
+ return {
+ status: 404,
+ headers: { 'Content-Type': 'application/json' },
+ body: this.createError('NOT_FOUND', 'Endpoint not found'),
+ };
+ });
}
protected async checkPermission(
diff --git a/ts/plugins.ts b/ts/plugins.ts
index 9936898..14238c6 100644
--- a/ts/plugins.ts
+++ b/ts/plugins.ts
@@ -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';
diff --git a/ts/pypi/classes.pypiregistry.ts b/ts/pypi/classes.pypiregistry.ts
index 9731f1e..92f0f31 100644
--- a/ts/pypi/classes.pypiregistry.ts
+++ b/ts/pypi/classes.pypiregistry.ts
@@ -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 {
- 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');
}
diff --git a/ts/rubygems/classes.rubygemsregistry.ts b/ts/rubygems/classes.rubygemsregistry.ts
index 440eadb..26a9d93 100644
--- a/ts/rubygems/classes.rubygemsregistry.ts
+++ b/ts/rubygems/classes.rubygemsregistry.ts
@@ -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 {
- 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