From 9643ef98b96b572fd93ea36fc0630f9b80263216 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 16 Apr 2026 10:42:33 +0000 Subject: [PATCH] feat(registry): add declarative protocol routing and request-scoped storage hook context across registries --- changelog.md | 9 + pnpm-lock.yaml | 26 + readme.md | 5 + test/helpers/fixtures.ts | 420 +++++++++++ test/helpers/ids.ts | 8 + test/helpers/providers.ts | 125 ++++ test/helpers/registry.ts | 903 +----------------------- test/helpers/registryconfig.ts | 122 ++++ test/helpers/storagebackend.ts | 72 ++ test/helpers/storagehooks.ts | 82 +++ test/helpers/tokens.ts | 27 + test/test.npm.ts | 46 +- test/test.storage.hooks.ts | 143 +++- ts/00_commitinfo_data.ts | 2 +- ts/cargo/classes.cargoregistry.ts | 45 +- ts/classes.smartregistry.ts | 316 ++++----- ts/composer/classes.composerregistry.ts | 129 ++-- ts/core/classes.baseregistry.ts | 119 +++- ts/core/classes.registrystorage.ts | 490 +++++-------- ts/core/helpers.registrystoragepaths.ts | 109 +++ ts/maven/classes.mavenregistry.ts | 66 +- ts/npm/classes.npmregistry.ts | 426 ++++++----- ts/npm/helpers.npmpublish.ts | 79 +++ ts/npm/helpers.npmroutes.ts | 110 +++ ts/oci/classes.ociregistry.ts | 127 ++-- ts/plugins.ts | 3 +- ts/pypi/classes.pypiregistry.ts | 136 ++-- ts/rubygems/classes.rubygemsregistry.ts | 101 ++- 28 files changed, 2327 insertions(+), 1919 deletions(-) create mode 100644 test/helpers/fixtures.ts create mode 100644 test/helpers/ids.ts create mode 100644 test/helpers/providers.ts create mode 100644 test/helpers/registryconfig.ts create mode 100644 test/helpers/storagebackend.ts create mode 100644 test/helpers/storagehooks.ts create mode 100644 test/helpers/tokens.ts create mode 100644 ts/core/helpers.registrystoragepaths.ts create mode 100644 ts/npm/helpers.npmpublish.ts create mode 100644 ts/npm/helpers.npmroutes.ts 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