Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10190a39fc | |||
| 9643ef98b9 | |||
| 09335d41f3 | |||
| 2221eef722 | |||
| 26ddf1a59f | |||
| 5acd1d6166 |
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Validate Maven tokens from Basic auth credentials by extracting the password portion before token validation.
|
||||||
|
- Return successful responses for PUT requests to checksum and maven-metadata endpoints so Maven deploy uploads do not fail when files are auto-generated.
|
||||||
|
- Improve npm CLI integration test isolation and cleanup by using a temporary test directory, copying per-package .npmrc files, and cleaning stale published packages before test runs.
|
||||||
|
- Tighten test teardown by destroying the registry explicitly and simplifying package/install fixture generation.
|
||||||
|
|
||||||
|
## 2026-03-24 - 2.8.1 - fix(registry)
|
||||||
|
align OCI and RubyGems API behavior and improve npm search result ordering
|
||||||
|
|
||||||
|
- handle OCI version checks on /v2 and /v2/ endpoints
|
||||||
|
- return RubyGems versions JSON in the expected flat array format and update unyank coverage to use the HTTP endpoint
|
||||||
|
- prioritize exact and prefix matches in npm search results
|
||||||
|
- update documentation to reflect full upstream proxy support
|
||||||
|
|
||||||
## 2026-03-24 - 2.8.0 - feat(core,storage,oci,registry-config)
|
## 2026-03-24 - 2.8.0 - feat(core,storage,oci,registry-config)
|
||||||
add streaming response support and configurable registry URLs across protocols
|
add streaming response support and configurable registry URLs across protocols
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartregistry",
|
"name": "@push.rocks/smartregistry",
|
||||||
"version": "2.8.0",
|
"version": "2.9.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries",
|
"description": "A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
Generated
+26
@@ -470,89 +470,105 @@ packages:
|
|||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.34.5':
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.34.5':
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-ppc64@0.34.5':
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-riscv64@0.34.5':
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.34.5':
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.5':
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.34.5':
|
||||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
@@ -1125,36 +1141,42 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.11':
|
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.11':
|
||||||
resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==}
|
resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11':
|
||||||
resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==}
|
resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11':
|
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11':
|
||||||
resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==}
|
resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.11':
|
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.11':
|
||||||
resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==}
|
resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.11':
|
'@rolldown/binding-linux-x64-musl@1.0.0-rc.11':
|
||||||
resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==}
|
resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.11':
|
'@rolldown/binding-openharmony-arm64@1.0.0-rc.11':
|
||||||
resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==}
|
resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==}
|
||||||
@@ -1196,21 +1218,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==}
|
resolution: {integrity: sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rspack/binding-linux-arm64-musl@1.7.10':
|
'@rspack/binding-linux-arm64-musl@1.7.10':
|
||||||
resolution: {integrity: sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==}
|
resolution: {integrity: sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rspack/binding-linux-x64-gnu@1.7.10':
|
'@rspack/binding-linux-x64-gnu@1.7.10':
|
||||||
resolution: {integrity: sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==}
|
resolution: {integrity: sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rspack/binding-linux-x64-musl@1.7.10':
|
'@rspack/binding-linux-x64-musl@1.7.10':
|
||||||
resolution: {integrity: sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==}
|
resolution: {integrity: sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rspack/binding-wasm32-wasi@1.7.10':
|
'@rspack/binding-wasm32-wasi@1.7.10':
|
||||||
resolution: {integrity: sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==}
|
resolution: {integrity: sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==}
|
||||||
|
|||||||
@@ -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)
|
- **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
|
- **Unified Authentication**: Scope-based permissions across all protocols
|
||||||
- **Path-based Routing**: `/oci/*`, `/npm/*`, `/maven/*`, `/cargo/*`, `/composer/*`, `/pypi/*`, `/rubygems/*`
|
- **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
|
### 🔐 Authentication & Authorization
|
||||||
- NPM UUID tokens for package operations
|
- NPM UUID tokens for package operations
|
||||||
@@ -41,7 +42,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
| Metadata API | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Metadata API | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Token Auth | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Token Auth | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Checksum Verification | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
| Checksum Verification | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
||||||
| Upstream Proxy | ✅ | ✅ | — | — | — | — | — |
|
| Upstream Proxy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### 🌐 Upstream Proxy & Caching
|
### 🌐 Upstream Proxy & Caching
|
||||||
- **Multi-Upstream Support**: Configure multiple upstream registries per protocol with priority ordering
|
- **Multi-Upstream Support**: Configure multiple upstream registries per protocol with priority ordering
|
||||||
@@ -60,6 +61,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
### 🔌 Enterprise Extensibility
|
### 🔌 Enterprise Extensibility
|
||||||
- **Storage Event Hooks** (`IStorageHooks`): Quota tracking, audit logging, virus scanning, cache invalidation
|
- **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 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
|
## 📥 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)
|
### 🦀 Cargo Registry (Rust Crates)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as smartarchive from '@push.rocks/smartarchive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to calculate SHA-256 digest in OCI format
|
||||||
|
*/
|
||||||
|
export function calculateDigest(data: Buffer): string {
|
||||||
|
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
return `sha256:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a minimal valid OCI manifest
|
||||||
|
*/
|
||||||
|
export function createTestManifest(configDigest: string, layerDigest: string) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
config: {
|
||||||
|
mediaType: 'application/vnd.oci.image.config.v1+json',
|
||||||
|
size: 123,
|
||||||
|
digest: configDigest,
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
||||||
|
size: 456,
|
||||||
|
digest: layerDigest,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a minimal valid NPM packument
|
||||||
|
*/
|
||||||
|
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
|
||||||
|
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
|
||||||
|
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: packageName,
|
||||||
|
versions: {
|
||||||
|
[version]: {
|
||||||
|
name: packageName,
|
||||||
|
version: version,
|
||||||
|
description: 'Test package',
|
||||||
|
main: 'index.js',
|
||||||
|
scripts: {},
|
||||||
|
dist: {
|
||||||
|
shasum: shasum,
|
||||||
|
integrity: integrity,
|
||||||
|
tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'dist-tags': {
|
||||||
|
latest: version,
|
||||||
|
},
|
||||||
|
_attachments: {
|
||||||
|
[`${packageName}-${version}.tgz`]: {
|
||||||
|
content_type: 'application/octet-stream',
|
||||||
|
data: tarballData.toString('base64'),
|
||||||
|
length: tarballData.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a minimal valid Maven POM file
|
||||||
|
*/
|
||||||
|
export function createTestPom(
|
||||||
|
groupId: string,
|
||||||
|
artifactId: string,
|
||||||
|
version: string,
|
||||||
|
packaging: string = 'jar'
|
||||||
|
): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>${groupId}</groupId>
|
||||||
|
<artifactId>${artifactId}</artifactId>
|
||||||
|
<version>${version}</version>
|
||||||
|
<packaging>${packaging}</packaging>
|
||||||
|
<name>${artifactId}</name>
|
||||||
|
<description>Test Maven artifact</description>
|
||||||
|
</project>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a test JAR file (minimal ZIP with manifest)
|
||||||
|
*/
|
||||||
|
export function createTestJar(): Buffer {
|
||||||
|
const manifestContent = `Manifest-Version: 1.0
|
||||||
|
Created-By: SmartRegistry Test
|
||||||
|
`;
|
||||||
|
|
||||||
|
return Buffer.from(manifestContent, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to calculate Maven checksums
|
||||||
|
*/
|
||||||
|
export function calculateMavenChecksums(data: Buffer) {
|
||||||
|
return {
|
||||||
|
md5: crypto.createHash('md5').update(data).digest('hex'),
|
||||||
|
sha1: crypto.createHash('sha1').update(data).digest('hex'),
|
||||||
|
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
||||||
|
sha512: crypto.createHash('sha512').update(data).digest('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a Composer package ZIP using smartarchive
|
||||||
|
*/
|
||||||
|
export async function createComposerZip(
|
||||||
|
vendorPackage: string,
|
||||||
|
version: string,
|
||||||
|
options?: {
|
||||||
|
description?: string;
|
||||||
|
license?: string[];
|
||||||
|
authors?: Array<{ name: string; email?: string }>;
|
||||||
|
}
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const zipTools = new smartarchive.ZipTools();
|
||||||
|
|
||||||
|
const composerJson = {
|
||||||
|
name: vendorPackage,
|
||||||
|
version: version,
|
||||||
|
type: 'library',
|
||||||
|
description: options?.description || 'Test Composer package',
|
||||||
|
license: options?.license || ['MIT'],
|
||||||
|
authors: options?.authors || [{ name: 'Test Author', email: 'test@example.com' }],
|
||||||
|
require: {
|
||||||
|
php: '>=7.4',
|
||||||
|
},
|
||||||
|
autoload: {
|
||||||
|
'psr-4': {
|
||||||
|
'Vendor\\TestPackage\\': 'src/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [vendor, pkg] = vendorPackage.split('/');
|
||||||
|
const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`;
|
||||||
|
const testPhpContent = `<?php
|
||||||
|
namespace ${namespace};
|
||||||
|
|
||||||
|
class TestClass
|
||||||
|
{
|
||||||
|
public function greet(): string
|
||||||
|
{
|
||||||
|
return "Hello from ${vendorPackage}!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const entries: smartarchive.IArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
archivePath: 'composer.json',
|
||||||
|
content: Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: 'src/TestClass.php',
|
||||||
|
content: Buffer.from(testPhpContent, 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: 'README.md',
|
||||||
|
content: Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Buffer.from(await zipTools.createZip(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a test Python wheel file (minimal ZIP structure) using smartarchive
|
||||||
|
*/
|
||||||
|
export async function createPythonWheel(
|
||||||
|
packageName: string,
|
||||||
|
version: string,
|
||||||
|
pyVersion: string = 'py3'
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const zipTools = new smartarchive.ZipTools();
|
||||||
|
|
||||||
|
const normalizedName = packageName.replace(/-/g, '_');
|
||||||
|
const distInfoDir = `${normalizedName}-${version}.dist-info`;
|
||||||
|
|
||||||
|
const metadata = `Metadata-Version: 2.1
|
||||||
|
Name: ${packageName}
|
||||||
|
Version: ${version}
|
||||||
|
Summary: Test Python package
|
||||||
|
Home-page: https://example.com
|
||||||
|
Author: Test Author
|
||||||
|
Author-email: test@example.com
|
||||||
|
License: MIT
|
||||||
|
Platform: UNKNOWN
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Requires-Python: >=3.7
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
|
||||||
|
# ${packageName}
|
||||||
|
|
||||||
|
Test package for SmartRegistry
|
||||||
|
`;
|
||||||
|
|
||||||
|
const wheelContent = `Wheel-Version: 1.0
|
||||||
|
Generator: test 1.0.0
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: ${pyVersion}-none-any
|
||||||
|
`;
|
||||||
|
|
||||||
|
const moduleContent = `"""${packageName} module"""
|
||||||
|
|
||||||
|
__version__ = "${version}"
|
||||||
|
|
||||||
|
def hello():
|
||||||
|
return "Hello from ${packageName}!"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const entries: smartarchive.IArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
archivePath: `${distInfoDir}/METADATA`,
|
||||||
|
content: Buffer.from(metadata, 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: `${distInfoDir}/WHEEL`,
|
||||||
|
content: Buffer.from(wheelContent, 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: `${distInfoDir}/RECORD`,
|
||||||
|
content: Buffer.from('', 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: `${distInfoDir}/top_level.txt`,
|
||||||
|
content: Buffer.from(normalizedName, 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: `${normalizedName}/__init__.py`,
|
||||||
|
content: Buffer.from(moduleContent, 'utf-8'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Buffer.from(await zipTools.createZip(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a test Python source distribution (sdist) using smartarchive
|
||||||
|
*/
|
||||||
|
export async function createPythonSdist(
|
||||||
|
packageName: string,
|
||||||
|
version: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const tarTools = new smartarchive.TarTools();
|
||||||
|
|
||||||
|
const normalizedName = packageName.replace(/-/g, '_');
|
||||||
|
const dirPrefix = `${packageName}-${version}`;
|
||||||
|
|
||||||
|
const pkgInfo = `Metadata-Version: 2.1
|
||||||
|
Name: ${packageName}
|
||||||
|
Version: ${version}
|
||||||
|
Summary: Test Python package
|
||||||
|
Home-page: https://example.com
|
||||||
|
Author: Test Author
|
||||||
|
Author-email: test@example.com
|
||||||
|
License: MIT
|
||||||
|
`;
|
||||||
|
|
||||||
|
const setupPy = `from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="${packageName}",
|
||||||
|
version="${version}",
|
||||||
|
packages=find_packages(),
|
||||||
|
python_requires=">=3.7",
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const moduleContent = `"""${packageName} module"""
|
||||||
|
|
||||||
|
__version__ = "${version}"
|
||||||
|
|
||||||
|
def hello():
|
||||||
|
return "Hello from ${packageName}!"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const entries: smartarchive.IArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
archivePath: `${dirPrefix}/PKG-INFO`,
|
||||||
|
content: Buffer.from(pkgInfo, 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: `${dirPrefix}/setup.py`,
|
||||||
|
content: Buffer.from(setupPy, 'utf-8'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: `${dirPrefix}/${normalizedName}/__init__.py`,
|
||||||
|
content: Buffer.from(moduleContent, 'utf-8'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Buffer.from(await tarTools.packFilesToTarGz(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to calculate PyPI file hashes
|
||||||
|
*/
|
||||||
|
export function calculatePypiHashes(data: Buffer) {
|
||||||
|
return {
|
||||||
|
md5: crypto.createHash('md5').update(data).digest('hex'),
|
||||||
|
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
||||||
|
blake2b: crypto.createHash('blake2b512').update(data).digest('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a test RubyGem file (minimal tar.gz structure) using smartarchive
|
||||||
|
*/
|
||||||
|
export async function createRubyGem(
|
||||||
|
gemName: string,
|
||||||
|
version: string,
|
||||||
|
platform: string = 'ruby'
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const tarTools = new smartarchive.TarTools();
|
||||||
|
const gzipTools = new smartarchive.GzipTools();
|
||||||
|
|
||||||
|
const metadataYaml = `--- !ruby/object:Gem::Specification
|
||||||
|
name: ${gemName}
|
||||||
|
version: !ruby/object:Gem::Version
|
||||||
|
version: ${version}
|
||||||
|
platform: ${platform}
|
||||||
|
authors:
|
||||||
|
- Test Author
|
||||||
|
autorequire:
|
||||||
|
bindir: bin
|
||||||
|
cert_chain: []
|
||||||
|
date: ${new Date().toISOString().split('T')[0]}
|
||||||
|
dependencies: []
|
||||||
|
description: Test RubyGem
|
||||||
|
email: test@example.com
|
||||||
|
executables: []
|
||||||
|
extensions: []
|
||||||
|
extra_rdoc_files: []
|
||||||
|
files:
|
||||||
|
- lib/${gemName}.rb
|
||||||
|
homepage: https://example.com
|
||||||
|
licenses:
|
||||||
|
- MIT
|
||||||
|
metadata: {}
|
||||||
|
post_install_message:
|
||||||
|
rdoc_options: []
|
||||||
|
require_paths:
|
||||||
|
- lib
|
||||||
|
required_ruby_version: !ruby/object:Gem::Requirement
|
||||||
|
requirements:
|
||||||
|
- - ">="
|
||||||
|
- !ruby/object:Gem::Version
|
||||||
|
version: '2.7'
|
||||||
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
||||||
|
requirements:
|
||||||
|
- - ">="
|
||||||
|
- !ruby/object:Gem::Version
|
||||||
|
version: '0'
|
||||||
|
requirements: []
|
||||||
|
rubygems_version: 3.0.0
|
||||||
|
signing_key:
|
||||||
|
specification_version: 4
|
||||||
|
summary: Test gem for SmartRegistry
|
||||||
|
test_files: []
|
||||||
|
`;
|
||||||
|
|
||||||
|
const metadataGz = Buffer.from(await gzipTools.compress(Buffer.from(metadataYaml, 'utf-8')));
|
||||||
|
|
||||||
|
const libContent = `# ${gemName}
|
||||||
|
|
||||||
|
module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')}
|
||||||
|
VERSION = "${version}"
|
||||||
|
|
||||||
|
def self.hello
|
||||||
|
"Hello from #{gemName}!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataEntries: smartarchive.IArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
archivePath: `lib/${gemName}.rb`,
|
||||||
|
content: Buffer.from(libContent, 'utf-8'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const dataTarGz = Buffer.from(await tarTools.packFilesToTarGz(dataEntries));
|
||||||
|
|
||||||
|
const gemEntries: smartarchive.IArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
archivePath: 'metadata.gz',
|
||||||
|
content: metadataGz,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
archivePath: 'data.tar.gz',
|
||||||
|
content: dataTarGz,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Buffer.from(await tarTools.packFiles(gemEntries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to calculate RubyGems checksums
|
||||||
|
*/
|
||||||
|
export function calculateRubyGemsChecksums(data: Buffer) {
|
||||||
|
return {
|
||||||
|
md5: crypto.createHash('md5').update(data).digest('hex'),
|
||||||
|
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { IAuthToken, TRegistryProtocol } from '../../ts/core/interfaces.core.js';
|
||||||
|
import type { IAuthProvider } from '../../ts/core/interfaces.auth.js';
|
||||||
|
import type {
|
||||||
|
IUpstreamProvider,
|
||||||
|
IUpstreamRegistryConfig,
|
||||||
|
IUpstreamResolutionContext,
|
||||||
|
IProtocolUpstreamConfig,
|
||||||
|
} from '../../ts/upstream/interfaces.upstream.js';
|
||||||
|
|
||||||
|
type TTestUpstreamRegistryConfig = Omit<Partial<IUpstreamRegistryConfig>, 'id' | 'url' | 'priority' | 'enabled'> &
|
||||||
|
Pick<IUpstreamRegistryConfig, 'id' | 'url' | 'priority' | 'enabled'>;
|
||||||
|
|
||||||
|
type TTestProtocolUpstreamConfig = Omit<IProtocolUpstreamConfig, 'upstreams'> & {
|
||||||
|
upstreams: TTestUpstreamRegistryConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUpstreamRegistryConfig(
|
||||||
|
upstream: TTestUpstreamRegistryConfig
|
||||||
|
): IUpstreamRegistryConfig {
|
||||||
|
return {
|
||||||
|
...upstream,
|
||||||
|
name: upstream.name ?? upstream.id,
|
||||||
|
auth: upstream.auth ?? { type: 'none' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProtocolUpstreamConfig(
|
||||||
|
config: TTestProtocolUpstreamConfig | undefined
|
||||||
|
): IProtocolUpstreamConfig | null {
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
upstreams: config.upstreams.map(normalizeUpstreamRegistryConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock upstream provider that tracks all calls for testing
|
||||||
|
*/
|
||||||
|
export function createTrackingUpstreamProvider(
|
||||||
|
baseConfig?: Partial<Record<TRegistryProtocol, TTestProtocolUpstreamConfig>>
|
||||||
|
): {
|
||||||
|
provider: IUpstreamProvider;
|
||||||
|
calls: IUpstreamResolutionContext[];
|
||||||
|
} {
|
||||||
|
const calls: IUpstreamResolutionContext[] = [];
|
||||||
|
|
||||||
|
const provider: IUpstreamProvider = {
|
||||||
|
async resolveUpstreamConfig(context: IUpstreamResolutionContext) {
|
||||||
|
calls.push({ ...context });
|
||||||
|
return normalizeProtocolUpstreamConfig(baseConfig?.[context.protocol]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { provider, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock auth provider for testing pluggable authentication.
|
||||||
|
* Allows customizing behavior for different test scenarios.
|
||||||
|
*/
|
||||||
|
export function createMockAuthProvider(overrides?: Partial<IAuthProvider>): IAuthProvider {
|
||||||
|
const tokens = new Map<string, IAuthToken>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: async () => {},
|
||||||
|
authenticate: async (credentials) => {
|
||||||
|
return credentials.username;
|
||||||
|
},
|
||||||
|
validateToken: async (token, protocol) => {
|
||||||
|
const stored = tokens.get(token);
|
||||||
|
if (stored && (!protocol || stored.type === protocol)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
if (token === 'valid-mock-token') {
|
||||||
|
return {
|
||||||
|
type: 'npm' as TRegistryProtocol,
|
||||||
|
userId: 'mock-user',
|
||||||
|
scopes: ['npm:*:*:*'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
createToken: async (userId, protocol, options) => {
|
||||||
|
const tokenId = `mock-${protocol}-${Date.now()}`;
|
||||||
|
const authToken: IAuthToken = {
|
||||||
|
type: protocol,
|
||||||
|
userId,
|
||||||
|
scopes: options?.scopes || [`${protocol}:*:*:*`],
|
||||||
|
readonly: options?.readonly,
|
||||||
|
expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn * 1000) : undefined,
|
||||||
|
};
|
||||||
|
tokens.set(tokenId, authToken);
|
||||||
|
return tokenId;
|
||||||
|
},
|
||||||
|
revokeToken: async (token) => {
|
||||||
|
tokens.delete(token);
|
||||||
|
},
|
||||||
|
authorize: async (token, resource, action) => {
|
||||||
|
if (!token) return false;
|
||||||
|
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
listUserTokens: async (userId) => {
|
||||||
|
const result: Array<{ key: string; readonly: boolean; created: string; protocol?: TRegistryProtocol }> = [];
|
||||||
|
for (const [key, token] of tokens.entries()) {
|
||||||
|
if (token.userId === userId) {
|
||||||
|
result.push({
|
||||||
|
key: `hash-${key.substring(0, 8)}`,
|
||||||
|
readonly: token.readonly || false,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
protocol: token.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
+34
-869
@@ -1,30 +1,34 @@
|
|||||||
import * as qenv from '@push.rocks/qenv';
|
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 * as smartbucket from '@push.rocks/smartbucket';
|
||||||
import { SmartRegistry } from '../../ts/classes.smartregistry.js';
|
import { SmartRegistry } from '../../ts/classes.smartregistry.js';
|
||||||
import type { IRegistryConfig, IAuthToken, TRegistryProtocol } from '../../ts/core/interfaces.core.js';
|
import type { IStorageHooks } from '../../ts/core/interfaces.storage.js';
|
||||||
import type { IAuthProvider, ITokenOptions } from '../../ts/core/interfaces.auth.js';
|
import type { IUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
|
||||||
import type { IStorageHooks, IStorageHookContext, IBeforePutResult, IBeforeDeleteResult } from '../../ts/core/interfaces.storage.js';
|
import { buildTestRegistryConfig, createDefaultTestUpstreamProvider } from './registryconfig.js';
|
||||||
import { StaticUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
|
import { generateTestRunId } from './ids.js';
|
||||||
import type { IUpstreamProvider, IUpstreamResolutionContext, IProtocolUpstreamConfig } from '../../ts/upstream/interfaces.upstream.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');
|
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up S3 bucket contents for a fresh test run
|
|
||||||
* @param prefix Optional prefix to delete (e.g., 'cargo/', 'npm/', 'composer/')
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Generate a unique test run ID for avoiding conflicts between test runs
|
|
||||||
* Uses timestamp + random suffix for uniqueness
|
|
||||||
*/
|
|
||||||
export function generateTestRunId(): string {
|
|
||||||
const timestamp = Date.now().toString(36);
|
|
||||||
const random = Math.random().toString(36).substring(2, 6);
|
|
||||||
return `${timestamp}${random}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanupS3Bucket(prefix?: string): Promise<void> {
|
export async function cleanupS3Bucket(prefix?: string): Promise<void> {
|
||||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
||||||
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
||||||
@@ -40,19 +44,17 @@ export async function cleanupS3Bucket(prefix?: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bucket = await s3.getBucket('test-registry');
|
const bucket = await s3.getBucketByName('test-registry');
|
||||||
if (bucket) {
|
if (bucket) {
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
// Delete only objects with the given prefix
|
// Delete only objects with the given prefix
|
||||||
const files = await bucket.fastList({ prefix });
|
for await (const path of bucket.listAllObjects(prefix)) {
|
||||||
for (const file of files) {
|
await bucket.fastRemove({ path });
|
||||||
await bucket.fastRemove({ path: file.name });
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Delete all objects in the bucket
|
// Delete all objects in the bucket
|
||||||
const files = await bucket.fastList({});
|
for await (const path of bucket.listAllObjects()) {
|
||||||
for (const file of files) {
|
await bucket.fastRemove({ path });
|
||||||
await bucket.fastRemove({ path: file.name });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,77 +69,9 @@ export async function cleanupS3Bucket(prefix?: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function createTestRegistry(options?: {
|
export async function createTestRegistry(options?: {
|
||||||
registryUrl?: string;
|
registryUrl?: string;
|
||||||
|
storageHooks?: IStorageHooks;
|
||||||
}): Promise<SmartRegistry> {
|
}): Promise<SmartRegistry> {
|
||||||
// Read S3 config from env.json
|
const config = await buildTestRegistryConfig(options);
|
||||||
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 registry = new SmartRegistry(config);
|
const registry = new SmartRegistry(config);
|
||||||
await registry.init();
|
await registry.init();
|
||||||
@@ -151,781 +85,12 @@ export async function createTestRegistry(options?: {
|
|||||||
export async function createTestRegistryWithUpstream(
|
export async function createTestRegistryWithUpstream(
|
||||||
upstreamProvider?: IUpstreamProvider
|
upstreamProvider?: IUpstreamProvider
|
||||||
): Promise<SmartRegistry> {
|
): Promise<SmartRegistry> {
|
||||||
// Read S3 config from env.json
|
const config = await buildTestRegistryConfig({
|
||||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
upstreamProvider: upstreamProvider || createDefaultTestUpstreamProvider(),
|
||||||
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: 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);
|
const registry = new SmartRegistry(config);
|
||||||
await registry.init();
|
await registry.init();
|
||||||
|
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock upstream provider that tracks all calls for testing
|
|
||||||
*/
|
|
||||||
export function createTrackingUpstreamProvider(
|
|
||||||
baseConfig?: Partial<Record<TRegistryProtocol, IProtocolUpstreamConfig>>
|
|
||||||
): {
|
|
||||||
provider: IUpstreamProvider;
|
|
||||||
calls: IUpstreamResolutionContext[];
|
|
||||||
} {
|
|
||||||
const calls: IUpstreamResolutionContext[] = [];
|
|
||||||
|
|
||||||
const provider: IUpstreamProvider = {
|
|
||||||
async resolveUpstreamConfig(context: IUpstreamResolutionContext) {
|
|
||||||
calls.push({ ...context });
|
|
||||||
return baseConfig?.[context.protocol] ?? null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return { provider, calls };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create test authentication tokens
|
|
||||||
*/
|
|
||||||
export async function createTestTokens(registry: SmartRegistry) {
|
|
||||||
const authManager = registry.getAuthManager();
|
|
||||||
|
|
||||||
// Authenticate and create tokens
|
|
||||||
const userId = await authManager.authenticate({
|
|
||||||
username: 'testuser',
|
|
||||||
password: 'testpass',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error('Failed to authenticate test user');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create NPM token
|
|
||||||
const npmToken = await authManager.createNpmToken(userId, false);
|
|
||||||
|
|
||||||
// Create OCI token with full access
|
|
||||||
const ociToken = await authManager.createOciToken(
|
|
||||||
userId,
|
|
||||||
['oci:repository:*:*'],
|
|
||||||
3600
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create Maven token with full access
|
|
||||||
const mavenToken = await authManager.createMavenToken(userId, false);
|
|
||||||
|
|
||||||
// Create Composer token with full access
|
|
||||||
const composerToken = await authManager.createComposerToken(userId, false);
|
|
||||||
|
|
||||||
// Create Cargo token with full access
|
|
||||||
const cargoToken = await authManager.createCargoToken(userId, false);
|
|
||||||
|
|
||||||
// Create PyPI token with full access
|
|
||||||
const pypiToken = await authManager.createPypiToken(userId, false);
|
|
||||||
|
|
||||||
// Create RubyGems token with full access
|
|
||||||
const rubygemsToken = await authManager.createRubyGemsToken(userId, false);
|
|
||||||
|
|
||||||
return { npmToken, ociToken, mavenToken, composerToken, cargoToken, pypiToken, rubygemsToken, userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to calculate SHA-256 digest in OCI format
|
|
||||||
*/
|
|
||||||
export function calculateDigest(data: Buffer): string {
|
|
||||||
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
||||||
return `sha256:${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a minimal valid OCI manifest
|
|
||||||
*/
|
|
||||||
export function createTestManifest(configDigest: string, layerDigest: string) {
|
|
||||||
return {
|
|
||||||
schemaVersion: 2,
|
|
||||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
|
||||||
config: {
|
|
||||||
mediaType: 'application/vnd.oci.image.config.v1+json',
|
|
||||||
size: 123,
|
|
||||||
digest: configDigest,
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
|
||||||
size: 456,
|
|
||||||
digest: layerDigest,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a minimal valid NPM packument
|
|
||||||
*/
|
|
||||||
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
|
|
||||||
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
|
|
||||||
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: packageName,
|
|
||||||
versions: {
|
|
||||||
[version]: {
|
|
||||||
name: packageName,
|
|
||||||
version: version,
|
|
||||||
description: 'Test package',
|
|
||||||
main: 'index.js',
|
|
||||||
scripts: {},
|
|
||||||
dist: {
|
|
||||||
shasum: shasum,
|
|
||||||
integrity: integrity,
|
|
||||||
tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'dist-tags': {
|
|
||||||
latest: version,
|
|
||||||
},
|
|
||||||
_attachments: {
|
|
||||||
[`${packageName}-${version}.tgz`]: {
|
|
||||||
content_type: 'application/octet-stream',
|
|
||||||
data: tarballData.toString('base64'),
|
|
||||||
length: tarballData.length,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a minimal valid Maven POM file
|
|
||||||
*/
|
|
||||||
export function createTestPom(
|
|
||||||
groupId: string,
|
|
||||||
artifactId: string,
|
|
||||||
version: string,
|
|
||||||
packaging: string = 'jar'
|
|
||||||
): string {
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
|
||||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
<groupId>${groupId}</groupId>
|
|
||||||
<artifactId>${artifactId}</artifactId>
|
|
||||||
<version>${version}</version>
|
|
||||||
<packaging>${packaging}</packaging>
|
|
||||||
<name>${artifactId}</name>
|
|
||||||
<description>Test Maven artifact</description>
|
|
||||||
</project>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a test JAR file (minimal ZIP with manifest)
|
|
||||||
*/
|
|
||||||
export function createTestJar(): Buffer {
|
|
||||||
// Create a simple JAR structure (just a manifest)
|
|
||||||
// In practice, this is a ZIP file with at least META-INF/MANIFEST.MF
|
|
||||||
const manifestContent = `Manifest-Version: 1.0
|
|
||||||
Created-By: SmartRegistry Test
|
|
||||||
`;
|
|
||||||
|
|
||||||
// For testing, we'll just create a buffer with dummy content
|
|
||||||
// Real JAR would be a proper ZIP archive
|
|
||||||
return Buffer.from(manifestContent, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to calculate Maven checksums
|
|
||||||
*/
|
|
||||||
export function calculateMavenChecksums(data: Buffer) {
|
|
||||||
return {
|
|
||||||
md5: crypto.createHash('md5').update(data).digest('hex'),
|
|
||||||
sha1: crypto.createHash('sha1').update(data).digest('hex'),
|
|
||||||
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
|
||||||
sha512: crypto.createHash('sha512').update(data).digest('hex'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a Composer package ZIP using smartarchive
|
|
||||||
*/
|
|
||||||
export async function createComposerZip(
|
|
||||||
vendorPackage: string,
|
|
||||||
version: string,
|
|
||||||
options?: {
|
|
||||||
description?: string;
|
|
||||||
license?: string[];
|
|
||||||
authors?: Array<{ name: string; email?: string }>;
|
|
||||||
}
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const zipTools = new smartarchive.ZipTools();
|
|
||||||
|
|
||||||
const composerJson = {
|
|
||||||
name: vendorPackage,
|
|
||||||
version: version,
|
|
||||||
type: 'library',
|
|
||||||
description: options?.description || 'Test Composer package',
|
|
||||||
license: options?.license || ['MIT'],
|
|
||||||
authors: options?.authors || [{ name: 'Test Author', email: 'test@example.com' }],
|
|
||||||
require: {
|
|
||||||
php: '>=7.4',
|
|
||||||
},
|
|
||||||
autoload: {
|
|
||||||
'psr-4': {
|
|
||||||
'Vendor\\TestPackage\\': 'src/',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add a test PHP file
|
|
||||||
const [vendor, pkg] = vendorPackage.split('/');
|
|
||||||
const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`;
|
|
||||||
const testPhpContent = `<?php
|
|
||||||
namespace ${namespace};
|
|
||||||
|
|
||||||
class TestClass
|
|
||||||
{
|
|
||||||
public function greet(): string
|
|
||||||
{
|
|
||||||
return "Hello from ${vendorPackage}!";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const entries: smartarchive.IArchiveEntry[] = [
|
|
||||||
{
|
|
||||||
archivePath: 'composer.json',
|
|
||||||
content: Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: 'src/TestClass.php',
|
|
||||||
content: Buffer.from(testPhpContent, 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: 'README.md',
|
|
||||||
content: Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Buffer.from(await zipTools.createZip(entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a test Python wheel file (minimal ZIP structure) using smartarchive
|
|
||||||
*/
|
|
||||||
export async function createPythonWheel(
|
|
||||||
packageName: string,
|
|
||||||
version: string,
|
|
||||||
pyVersion: string = 'py3'
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const zipTools = new smartarchive.ZipTools();
|
|
||||||
|
|
||||||
const normalizedName = packageName.replace(/-/g, '_');
|
|
||||||
const distInfoDir = `${normalizedName}-${version}.dist-info`;
|
|
||||||
|
|
||||||
// Create METADATA file
|
|
||||||
const metadata = `Metadata-Version: 2.1
|
|
||||||
Name: ${packageName}
|
|
||||||
Version: ${version}
|
|
||||||
Summary: Test Python package
|
|
||||||
Home-page: https://example.com
|
|
||||||
Author: Test Author
|
|
||||||
Author-email: test@example.com
|
|
||||||
License: MIT
|
|
||||||
Platform: UNKNOWN
|
|
||||||
Classifier: Programming Language :: Python :: 3
|
|
||||||
Requires-Python: >=3.7
|
|
||||||
Description-Content-Type: text/markdown
|
|
||||||
|
|
||||||
# ${packageName}
|
|
||||||
|
|
||||||
Test package for SmartRegistry
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Create WHEEL file
|
|
||||||
const wheelContent = `Wheel-Version: 1.0
|
|
||||||
Generator: test 1.0.0
|
|
||||||
Root-Is-Purelib: true
|
|
||||||
Tag: ${pyVersion}-none-any
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Create a simple Python module
|
|
||||||
const moduleContent = `"""${packageName} module"""
|
|
||||||
|
|
||||||
__version__ = "${version}"
|
|
||||||
|
|
||||||
def hello():
|
|
||||||
return "Hello from ${packageName}!"
|
|
||||||
`;
|
|
||||||
|
|
||||||
const entries: smartarchive.IArchiveEntry[] = [
|
|
||||||
{
|
|
||||||
archivePath: `${distInfoDir}/METADATA`,
|
|
||||||
content: Buffer.from(metadata, 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: `${distInfoDir}/WHEEL`,
|
|
||||||
content: Buffer.from(wheelContent, 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: `${distInfoDir}/RECORD`,
|
|
||||||
content: Buffer.from('', 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: `${distInfoDir}/top_level.txt`,
|
|
||||||
content: Buffer.from(normalizedName, 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: `${normalizedName}/__init__.py`,
|
|
||||||
content: Buffer.from(moduleContent, 'utf-8'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Buffer.from(await zipTools.createZip(entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a test Python source distribution (sdist) using smartarchive
|
|
||||||
*/
|
|
||||||
export async function createPythonSdist(
|
|
||||||
packageName: string,
|
|
||||||
version: string
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const tarTools = new smartarchive.TarTools();
|
|
||||||
|
|
||||||
const normalizedName = packageName.replace(/-/g, '_');
|
|
||||||
const dirPrefix = `${packageName}-${version}`;
|
|
||||||
|
|
||||||
// PKG-INFO
|
|
||||||
const pkgInfo = `Metadata-Version: 2.1
|
|
||||||
Name: ${packageName}
|
|
||||||
Version: ${version}
|
|
||||||
Summary: Test Python package
|
|
||||||
Home-page: https://example.com
|
|
||||||
Author: Test Author
|
|
||||||
Author-email: test@example.com
|
|
||||||
License: MIT
|
|
||||||
`;
|
|
||||||
|
|
||||||
// setup.py
|
|
||||||
const setupPy = `from setuptools import setup, find_packages
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="${packageName}",
|
|
||||||
version="${version}",
|
|
||||||
packages=find_packages(),
|
|
||||||
python_requires=">=3.7",
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Module file
|
|
||||||
const moduleContent = `"""${packageName} module"""
|
|
||||||
|
|
||||||
__version__ = "${version}"
|
|
||||||
|
|
||||||
def hello():
|
|
||||||
return "Hello from ${packageName}!"
|
|
||||||
`;
|
|
||||||
|
|
||||||
const entries: smartarchive.IArchiveEntry[] = [
|
|
||||||
{
|
|
||||||
archivePath: `${dirPrefix}/PKG-INFO`,
|
|
||||||
content: Buffer.from(pkgInfo, 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: `${dirPrefix}/setup.py`,
|
|
||||||
content: Buffer.from(setupPy, 'utf-8'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: `${dirPrefix}/${normalizedName}/__init__.py`,
|
|
||||||
content: Buffer.from(moduleContent, 'utf-8'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Buffer.from(await tarTools.packFilesToTarGz(entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to calculate PyPI file hashes
|
|
||||||
*/
|
|
||||||
export function calculatePypiHashes(data: Buffer) {
|
|
||||||
return {
|
|
||||||
md5: crypto.createHash('md5').update(data).digest('hex'),
|
|
||||||
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
|
||||||
blake2b: crypto.createHash('blake2b512').update(data).digest('hex'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a test RubyGem file (minimal tar.gz structure) using smartarchive
|
|
||||||
*/
|
|
||||||
export async function createRubyGem(
|
|
||||||
gemName: string,
|
|
||||||
version: string,
|
|
||||||
platform: string = 'ruby'
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const tarTools = new smartarchive.TarTools();
|
|
||||||
const gzipTools = new smartarchive.GzipTools();
|
|
||||||
|
|
||||||
// Create metadata.gz (simplified)
|
|
||||||
const metadataYaml = `--- !ruby/object:Gem::Specification
|
|
||||||
name: ${gemName}
|
|
||||||
version: !ruby/object:Gem::Version
|
|
||||||
version: ${version}
|
|
||||||
platform: ${platform}
|
|
||||||
authors:
|
|
||||||
- Test Author
|
|
||||||
autorequire:
|
|
||||||
bindir: bin
|
|
||||||
cert_chain: []
|
|
||||||
date: ${new Date().toISOString().split('T')[0]}
|
|
||||||
dependencies: []
|
|
||||||
description: Test RubyGem
|
|
||||||
email: test@example.com
|
|
||||||
executables: []
|
|
||||||
extensions: []
|
|
||||||
extra_rdoc_files: []
|
|
||||||
files:
|
|
||||||
- lib/${gemName}.rb
|
|
||||||
homepage: https://example.com
|
|
||||||
licenses:
|
|
||||||
- MIT
|
|
||||||
metadata: {}
|
|
||||||
post_install_message:
|
|
||||||
rdoc_options: []
|
|
||||||
require_paths:
|
|
||||||
- lib
|
|
||||||
required_ruby_version: !ruby/object:Gem::Requirement
|
|
||||||
requirements:
|
|
||||||
- - ">="
|
|
||||||
- !ruby/object:Gem::Version
|
|
||||||
version: '2.7'
|
|
||||||
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
||||||
requirements:
|
|
||||||
- - ">="
|
|
||||||
- !ruby/object:Gem::Version
|
|
||||||
version: '0'
|
|
||||||
requirements: []
|
|
||||||
rubygems_version: 3.0.0
|
|
||||||
signing_key:
|
|
||||||
specification_version: 4
|
|
||||||
summary: Test gem for SmartRegistry
|
|
||||||
test_files: []
|
|
||||||
`;
|
|
||||||
|
|
||||||
const metadataGz = Buffer.from(await gzipTools.compress(Buffer.from(metadataYaml, 'utf-8')));
|
|
||||||
|
|
||||||
// Create data.tar.gz content
|
|
||||||
const libContent = `# ${gemName}
|
|
||||||
|
|
||||||
module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')}
|
|
||||||
VERSION = "${version}"
|
|
||||||
|
|
||||||
def self.hello
|
|
||||||
"Hello from #{gemName}!"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
|
|
||||||
const dataEntries: smartarchive.IArchiveEntry[] = [
|
|
||||||
{
|
|
||||||
archivePath: `lib/${gemName}.rb`,
|
|
||||||
content: Buffer.from(libContent, 'utf-8'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const dataTarGz = Buffer.from(await tarTools.packFilesToTarGz(dataEntries));
|
|
||||||
|
|
||||||
// Create the outer gem (tar.gz containing metadata.gz and data.tar.gz)
|
|
||||||
const gemEntries: smartarchive.IArchiveEntry[] = [
|
|
||||||
{
|
|
||||||
archivePath: 'metadata.gz',
|
|
||||||
content: metadataGz,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
archivePath: 'data.tar.gz',
|
|
||||||
content: dataTarGz,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// RubyGems .gem files are plain tar archives (NOT gzipped), containing metadata.gz and data.tar.gz
|
|
||||||
return Buffer.from(await tarTools.packFiles(gemEntries));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to calculate RubyGems checksums
|
|
||||||
*/
|
|
||||||
export function calculateRubyGemsChecksums(data: Buffer) {
|
|
||||||
return {
|
|
||||||
md5: crypto.createHash('md5').update(data).digest('hex'),
|
|
||||||
sha256: crypto.createHash('sha256').update(data).digest('hex'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Enterprise Extensibility Test Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock auth provider for testing pluggable authentication.
|
|
||||||
* Allows customizing behavior for different test scenarios.
|
|
||||||
*/
|
|
||||||
export function createMockAuthProvider(overrides?: Partial<IAuthProvider>): IAuthProvider {
|
|
||||||
const tokens = new Map<string, IAuthToken>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
init: async () => {},
|
|
||||||
authenticate: async (credentials) => {
|
|
||||||
// Default: always authenticate successfully
|
|
||||||
return credentials.username;
|
|
||||||
},
|
|
||||||
validateToken: async (token, protocol) => {
|
|
||||||
const stored = tokens.get(token);
|
|
||||||
if (stored && (!protocol || stored.type === protocol)) {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
// Mock token for tests
|
|
||||||
if (token === 'valid-mock-token') {
|
|
||||||
return {
|
|
||||||
type: 'npm' as TRegistryProtocol,
|
|
||||||
userId: 'mock-user',
|
|
||||||
scopes: ['npm:*:*:*'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
createToken: async (userId, protocol, options) => {
|
|
||||||
const tokenId = `mock-${protocol}-${Date.now()}`;
|
|
||||||
const authToken: IAuthToken = {
|
|
||||||
type: protocol,
|
|
||||||
userId,
|
|
||||||
scopes: options?.scopes || [`${protocol}:*:*:*`],
|
|
||||||
readonly: options?.readonly,
|
|
||||||
expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn * 1000) : undefined,
|
|
||||||
};
|
|
||||||
tokens.set(tokenId, authToken);
|
|
||||||
return tokenId;
|
|
||||||
},
|
|
||||||
revokeToken: async (token) => {
|
|
||||||
tokens.delete(token);
|
|
||||||
},
|
|
||||||
authorize: async (token, resource, action) => {
|
|
||||||
if (!token) return false;
|
|
||||||
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
listUserTokens: async (userId) => {
|
|
||||||
const result: Array<{ key: string; readonly: boolean; created: string; protocol?: TRegistryProtocol }> = [];
|
|
||||||
for (const [key, token] of tokens.entries()) {
|
|
||||||
if (token.userId === userId) {
|
|
||||||
result.push({
|
|
||||||
key: `hash-${key.substring(0, 8)}`,
|
|
||||||
readonly: token.readonly || false,
|
|
||||||
created: new Date().toISOString(),
|
|
||||||
protocol: token.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create test storage hooks that track all calls.
|
|
||||||
* Useful for verifying hook invocation order and parameters.
|
|
||||||
*/
|
|
||||||
export function createTrackingHooks(options?: {
|
|
||||||
beforePutAllowed?: boolean;
|
|
||||||
beforeDeleteAllowed?: boolean;
|
|
||||||
throwOnAfterPut?: boolean;
|
|
||||||
throwOnAfterGet?: boolean;
|
|
||||||
}): {
|
|
||||||
hooks: IStorageHooks;
|
|
||||||
calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }>;
|
|
||||||
} {
|
|
||||||
const calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }> = [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
calls,
|
|
||||||
hooks: {
|
|
||||||
beforePut: async (ctx) => {
|
|
||||||
calls.push({ method: 'beforePut', context: ctx, timestamp: Date.now() });
|
|
||||||
return {
|
|
||||||
allowed: options?.beforePutAllowed !== false,
|
|
||||||
reason: options?.beforePutAllowed === false ? 'Blocked by test' : undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
afterPut: async (ctx) => {
|
|
||||||
calls.push({ method: 'afterPut', context: ctx, timestamp: Date.now() });
|
|
||||||
if (options?.throwOnAfterPut) {
|
|
||||||
throw new Error('Test error in afterPut');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeDelete: async (ctx) => {
|
|
||||||
calls.push({ method: 'beforeDelete', context: ctx, timestamp: Date.now() });
|
|
||||||
return {
|
|
||||||
allowed: options?.beforeDeleteAllowed !== false,
|
|
||||||
reason: options?.beforeDeleteAllowed === false ? 'Blocked by test' : undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
afterDelete: async (ctx) => {
|
|
||||||
calls.push({ method: 'afterDelete', context: ctx, timestamp: Date.now() });
|
|
||||||
},
|
|
||||||
afterGet: async (ctx) => {
|
|
||||||
calls.push({ method: 'afterGet', context: ctx, timestamp: Date.now() });
|
|
||||||
if (options?.throwOnAfterGet) {
|
|
||||||
throw new Error('Test error in afterGet');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a blocking storage hooks implementation for quota testing.
|
|
||||||
*/
|
|
||||||
export function createQuotaHooks(maxSizeBytes: number): {
|
|
||||||
hooks: IStorageHooks;
|
|
||||||
currentUsage: { bytes: number };
|
|
||||||
} {
|
|
||||||
const currentUsage = { bytes: 0 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentUsage,
|
|
||||||
hooks: {
|
|
||||||
beforePut: async (ctx) => {
|
|
||||||
const size = ctx.metadata?.size || 0;
|
|
||||||
if (currentUsage.bytes + size > maxSizeBytes) {
|
|
||||||
return { allowed: false, reason: `Quota exceeded: ${currentUsage.bytes + size} > ${maxSizeBytes}` };
|
|
||||||
}
|
|
||||||
return { allowed: true };
|
|
||||||
},
|
|
||||||
afterPut: async (ctx) => {
|
|
||||||
currentUsage.bytes += ctx.metadata?.size || 0;
|
|
||||||
},
|
|
||||||
afterDelete: async (ctx) => {
|
|
||||||
currentUsage.bytes -= ctx.metadata?.size || 0;
|
|
||||||
if (currentUsage.bytes < 0) currentUsage.bytes = 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a SmartBucket storage backend for upstream cache testing.
|
|
||||||
*/
|
|
||||||
export async function createTestStorageBackend(): Promise<{
|
|
||||||
storage: {
|
|
||||||
getObject: (key: string) => Promise<Buffer | null>;
|
|
||||||
putObject: (key: string, data: Buffer) => Promise<void>;
|
|
||||||
deleteObject: (key: string) => Promise<void>;
|
|
||||||
listObjects: (prefix: string) => Promise<string[]>;
|
|
||||||
};
|
|
||||||
bucket: smartbucket.Bucket;
|
|
||||||
cleanup: () => Promise<void>;
|
|
||||||
}> {
|
|
||||||
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
|
||||||
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
|
||||||
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
|
||||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
|
||||||
|
|
||||||
const s3 = new smartbucket.SmartBucket({
|
|
||||||
accessKey: s3AccessKey || 'minioadmin',
|
|
||||||
accessSecret: s3SecretKey || 'minioadmin',
|
|
||||||
endpoint: s3Endpoint || 'localhost',
|
|
||||||
port: parseInt(s3Port || '9000', 10),
|
|
||||||
useSsl: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const testRunId = generateTestRunId();
|
|
||||||
const bucketName = 'test-cache-' + testRunId.substring(0, 8);
|
|
||||||
const bucket = await s3.createBucket(bucketName);
|
|
||||||
|
|
||||||
const storage = {
|
|
||||||
getObject: async (key: string): Promise<Buffer | null> => {
|
|
||||||
try {
|
|
||||||
const file = await bucket.fastGet({ path: key });
|
|
||||||
if (!file) return null;
|
|
||||||
const stream = await file.createReadStream();
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
chunks.push(Buffer.from(chunk));
|
|
||||||
}
|
|
||||||
return Buffer.concat(chunks);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
putObject: async (key: string, data: Buffer): Promise<void> => {
|
|
||||||
await bucket.fastPut({ path: key, contents: data, overwrite: true });
|
|
||||||
},
|
|
||||||
deleteObject: async (key: string): Promise<void> => {
|
|
||||||
await bucket.fastRemove({ path: key });
|
|
||||||
},
|
|
||||||
listObjects: async (prefix: string): Promise<string[]> => {
|
|
||||||
const files = await bucket.fastList({ prefix });
|
|
||||||
return files.map(f => f.name);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
|
||||||
try {
|
|
||||||
const files = await bucket.fastList({});
|
|
||||||
for (const file of files) {
|
|
||||||
await bucket.fastRemove({ path: file.name });
|
|
||||||
}
|
|
||||||
await s3.removeBucket(bucketName);
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { storage, bucket, cleanup };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
import type { IRegistryConfig } from '../../ts/core/interfaces.core.js';
|
||||||
|
import type { IStorageHooks } from '../../ts/core/interfaces.storage.js';
|
||||||
|
import { StaticUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
|
||||||
|
import type { IUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
|
||||||
|
|
||||||
|
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||||
|
|
||||||
|
async function getTestStorageConfig(): Promise<IRegistryConfig['storage']> {
|
||||||
|
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
||||||
|
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
||||||
|
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
||||||
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessKey: s3AccessKey || 'minioadmin',
|
||||||
|
accessSecret: s3SecretKey || 'minioadmin',
|
||||||
|
endpoint: s3Endpoint || 'localhost',
|
||||||
|
port: parseInt(s3Port || '9000', 10),
|
||||||
|
useSsl: false,
|
||||||
|
region: 'us-east-1',
|
||||||
|
bucketName: 'test-registry',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTestAuthConfig(): IRegistryConfig['auth'] {
|
||||||
|
return {
|
||||||
|
jwtSecret: 'test-secret-key',
|
||||||
|
tokenStore: 'memory',
|
||||||
|
npmTokens: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
ociTokens: {
|
||||||
|
enabled: true,
|
||||||
|
realm: 'https://auth.example.com/token',
|
||||||
|
service: 'test-registry',
|
||||||
|
},
|
||||||
|
pypiTokens: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
rubygemsTokens: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultTestUpstreamProvider(): IUpstreamProvider {
|
||||||
|
return new StaticUpstreamProvider({
|
||||||
|
npm: {
|
||||||
|
enabled: true,
|
||||||
|
upstreams: [{
|
||||||
|
id: 'npmjs',
|
||||||
|
name: 'npmjs',
|
||||||
|
url: 'https://registry.npmjs.org',
|
||||||
|
priority: 1,
|
||||||
|
enabled: true,
|
||||||
|
auth: { type: 'none' },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
oci: {
|
||||||
|
enabled: true,
|
||||||
|
upstreams: [{
|
||||||
|
id: 'dockerhub',
|
||||||
|
name: 'dockerhub',
|
||||||
|
url: 'https://registry-1.docker.io',
|
||||||
|
priority: 1,
|
||||||
|
enabled: true,
|
||||||
|
auth: { type: 'none' },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTestRegistryConfig(options?: {
|
||||||
|
registryUrl?: string;
|
||||||
|
storageHooks?: IStorageHooks;
|
||||||
|
upstreamProvider?: IUpstreamProvider;
|
||||||
|
}): Promise<IRegistryConfig> {
|
||||||
|
const config: IRegistryConfig = {
|
||||||
|
storage: await getTestStorageConfig(),
|
||||||
|
auth: getTestAuthConfig(),
|
||||||
|
...(options?.storageHooks ? { storageHooks: options.storageHooks } : {}),
|
||||||
|
...(options?.upstreamProvider ? { upstreamProvider: options.upstreamProvider } : {}),
|
||||||
|
oci: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/oci',
|
||||||
|
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/oci` } : {}),
|
||||||
|
},
|
||||||
|
npm: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/npm',
|
||||||
|
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/npm` } : {}),
|
||||||
|
},
|
||||||
|
maven: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/maven',
|
||||||
|
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/maven` } : {}),
|
||||||
|
},
|
||||||
|
composer: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/composer',
|
||||||
|
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/composer` } : {}),
|
||||||
|
},
|
||||||
|
cargo: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/cargo',
|
||||||
|
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/cargo` } : {}),
|
||||||
|
},
|
||||||
|
pypi: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/pypi',
|
||||||
|
...(options?.registryUrl ? { registryUrl: options.registryUrl } : {}),
|
||||||
|
},
|
||||||
|
rubygems: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/rubygems',
|
||||||
|
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/rubygems` } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
import * as smartbucket from '@push.rocks/smartbucket';
|
||||||
|
import { generateTestRunId } from './ids.js';
|
||||||
|
|
||||||
|
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a SmartBucket storage backend for upstream cache testing.
|
||||||
|
*/
|
||||||
|
export async function createTestStorageBackend(): Promise<{
|
||||||
|
storage: {
|
||||||
|
getObject: (key: string) => Promise<Buffer | null>;
|
||||||
|
putObject: (key: string, data: Buffer) => Promise<void>;
|
||||||
|
deleteObject: (key: string) => Promise<void>;
|
||||||
|
listObjects: (prefix: string) => Promise<string[]>;
|
||||||
|
};
|
||||||
|
bucket: smartbucket.Bucket;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
||||||
|
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
||||||
|
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
||||||
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||||
|
|
||||||
|
const s3 = new smartbucket.SmartBucket({
|
||||||
|
accessKey: s3AccessKey || 'minioadmin',
|
||||||
|
accessSecret: s3SecretKey || 'minioadmin',
|
||||||
|
endpoint: s3Endpoint || 'localhost',
|
||||||
|
port: parseInt(s3Port || '9000', 10),
|
||||||
|
useSsl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testRunId = generateTestRunId();
|
||||||
|
const bucketName = 'test-cache-' + testRunId.substring(0, 8);
|
||||||
|
const bucket = await s3.createBucket(bucketName);
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
getObject: async (key: string): Promise<Buffer | null> => {
|
||||||
|
try {
|
||||||
|
return await bucket.fastGet({ path: key });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
putObject: async (key: string, data: Buffer): Promise<void> => {
|
||||||
|
await bucket.fastPut({ path: key, contents: data, overwrite: true });
|
||||||
|
},
|
||||||
|
deleteObject: async (key: string): Promise<void> => {
|
||||||
|
await bucket.fastRemove({ path: key });
|
||||||
|
},
|
||||||
|
listObjects: async (prefix: string): Promise<string[]> => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
for await (const path of bucket.listAllObjects(prefix)) {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
try {
|
||||||
|
for await (const path of bucket.listAllObjects()) {
|
||||||
|
await bucket.fastRemove({ path });
|
||||||
|
}
|
||||||
|
await s3.removeBucket(bucketName);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { storage, bucket, cleanup };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -268,8 +268,8 @@ tap.test('Cargo: should store crate in smarts3', async () => {
|
|||||||
* Cleanup: Stop smartstorage server
|
* Cleanup: Stop smartstorage server
|
||||||
*/
|
*/
|
||||||
tap.test('should stop smartstorage server', async () => {
|
tap.test('should stop smartstorage server', async () => {
|
||||||
|
registry.destroy();
|
||||||
await s3Server.stop();
|
await s3Server.stop();
|
||||||
expect(true).toEqual(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
|
||||||
import { SmartRegistry } from '../ts/index.js';
|
import { SmartRegistry } from '../ts/index.js';
|
||||||
import { createTestRegistry, createTestTokens } from './helpers/registry.js';
|
import { createTestRegistry, createTestTokens, cleanupS3Bucket } from './helpers/registry.js';
|
||||||
import type { IRequestContext, IResponse } from '../ts/core/interfaces.core.js';
|
import type { IRequestContext, IResponse } from '../ts/core/interfaces.core.js';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
// Test context
|
// Test state
|
||||||
let registry: SmartRegistry;
|
let registry: SmartRegistry;
|
||||||
let server: http.Server;
|
let server: http.Server;
|
||||||
let registryUrl: string;
|
let registryUrl: string;
|
||||||
@@ -32,21 +32,22 @@ async function createHttpServer(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Parse request
|
const parsedUrl = new url.URL(req.url || '/', `http://localhost:${port}`);
|
||||||
const parsedUrl = url.parse(req.url || '', true);
|
const pathname = parsedUrl.pathname;
|
||||||
const pathname = parsedUrl.pathname || '/';
|
const query: Record<string, string> = {};
|
||||||
const query = parsedUrl.query;
|
parsedUrl.searchParams.forEach((value, key) => {
|
||||||
|
query[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
// Read body
|
// Read body
|
||||||
|
let body: any = undefined;
|
||||||
|
if (req.method === 'PUT' || req.method === 'POST' || req.method === 'PATCH') {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of req) {
|
for await (const chunk of req) {
|
||||||
chunks.push(chunk);
|
chunks.push(Buffer.from(chunk));
|
||||||
}
|
}
|
||||||
const bodyBuffer = Buffer.concat(chunks);
|
const bodyBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
// Parse body based on content type
|
|
||||||
let body: any;
|
|
||||||
if (bodyBuffer.length > 0) {
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
const contentType = req.headers['content-type'] || '';
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +125,7 @@ function createTestPackage(
|
|||||||
version: string,
|
version: string,
|
||||||
targetDir: string
|
targetDir: string
|
||||||
): string {
|
): string {
|
||||||
const packageDir = path.join(targetDir, packageName);
|
const packageDir = path.join(targetDir, packageName.replace(/\//g, '-'));
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
// Create package.json
|
// Create package.json
|
||||||
@@ -133,12 +134,7 @@ function createTestPackage(
|
|||||||
version: version,
|
version: version,
|
||||||
description: `Test package ${packageName}`,
|
description: `Test package ${packageName}`,
|
||||||
main: 'index.js',
|
main: 'index.js',
|
||||||
scripts: {
|
scripts: {},
|
||||||
test: 'echo "Test passed"',
|
|
||||||
},
|
|
||||||
keywords: ['test'],
|
|
||||||
author: 'Test Author',
|
|
||||||
license: 'MIT',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -147,25 +143,24 @@ function createTestPackage(
|
|||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create index.js
|
// Create a simple index.js
|
||||||
const indexJs = `module.exports = {
|
fs.writeFileSync(
|
||||||
name: '${packageName}',
|
path.join(packageDir, 'index.js'),
|
||||||
version: '${version}',
|
`module.exports = { name: '${packageName}', version: '${version}' };\n`,
|
||||||
message: 'Hello from ${packageName}@${version}'
|
'utf-8'
|
||||||
};
|
);
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(packageDir, 'index.js'), indexJs, 'utf-8');
|
|
||||||
|
|
||||||
// Create README.md
|
// Create README.md
|
||||||
const readme = `# ${packageName}
|
fs.writeFileSync(
|
||||||
|
path.join(packageDir, 'README.md'),
|
||||||
|
`# ${packageName}\n\nTest package version ${version}\n`,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
Test package for SmartRegistry.
|
// Copy .npmrc into the package directory
|
||||||
|
if (npmrcPath && fs.existsSync(npmrcPath)) {
|
||||||
Version: ${version}
|
fs.copyFileSync(npmrcPath, path.join(packageDir, '.npmrc'));
|
||||||
`;
|
}
|
||||||
|
|
||||||
fs.writeFileSync(path.join(packageDir, 'README.md'), readme, 'utf-8');
|
|
||||||
|
|
||||||
return packageDir;
|
return packageDir;
|
||||||
}
|
}
|
||||||
@@ -177,31 +172,30 @@ async function runNpmCommand(
|
|||||||
command: string,
|
command: string,
|
||||||
cwd: string
|
cwd: string
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
// Prepare environment variables
|
const { exec } = await import('child_process');
|
||||||
const envVars = [
|
|
||||||
`NPM_CONFIG_USERCONFIG="${npmrcPath}"`,
|
|
||||||
`NPM_CONFIG_CACHE="${path.join(testDir, '.npm-cache')}"`,
|
|
||||||
`NPM_CONFIG_PREFIX="${path.join(testDir, '.npm-global')}"`,
|
|
||||||
`NPM_CONFIG_REGISTRY="${registryUrl}/npm/"`,
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
// Build command with cd to correct directory and environment variables
|
// Build isolated env that prevents npm from reading ~/.npmrc
|
||||||
const fullCommand = `cd "${cwd}" && ${envVars} ${command}`;
|
const env: Record<string, string> = {};
|
||||||
|
// Copy only essential env vars (PATH, etc.) — exclude HOME to prevent ~/.npmrc reading
|
||||||
try {
|
for (const key of ['PATH', 'NODE', 'NVM_DIR', 'NVM_BIN', 'LANG', 'TERM', 'SHELL']) {
|
||||||
const result = await tapNodeTools.runCommand(fullCommand);
|
if (process.env[key]) env[key] = process.env[key]!;
|
||||||
return {
|
|
||||||
stdout: result.stdout || '',
|
|
||||||
stderr: result.stderr || '',
|
|
||||||
exitCode: result.exitCode || 0,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
stdout: error.stdout || '',
|
|
||||||
stderr: error.stderr || String(error),
|
|
||||||
exitCode: error.exitCode || 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
env.HOME = testDir;
|
||||||
|
env.NPM_CONFIG_USERCONFIG = npmrcPath;
|
||||||
|
env.NPM_CONFIG_GLOBALCONFIG = '/dev/null';
|
||||||
|
env.NPM_CONFIG_CACHE = path.join(testDir, '.npm-cache');
|
||||||
|
env.NPM_CONFIG_PREFIX = path.join(testDir, '.npm-global');
|
||||||
|
env.NPM_CONFIG_REGISTRY = `${registryUrl}/npm/`;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec(command, { cwd, env, timeout: 30000 }, (error, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
stdout: stdout || '',
|
||||||
|
stderr: stderr || '',
|
||||||
|
exitCode: error ? (error as any).code ?? 1 : 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,6 +220,16 @@ tap.test('NPM CLI: should setup registry and HTTP server', async () => {
|
|||||||
const tokens = await createTestTokens(registry);
|
const tokens = await createTestTokens(registry);
|
||||||
npmToken = tokens.npmToken;
|
npmToken = tokens.npmToken;
|
||||||
|
|
||||||
|
// Clean up stale npm CLI test data via unpublish API
|
||||||
|
for (const pkg of ['test-package-cli', '@testscope%2fscoped-package']) {
|
||||||
|
await registry.handleRequest({
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/npm/${pkg}/-rev/cleanup`,
|
||||||
|
headers: { Authorization: `Bearer ${npmToken}` },
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
expect(registry).toBeInstanceOf(SmartRegistry);
|
expect(registry).toBeInstanceOf(SmartRegistry);
|
||||||
expect(npmToken).toBeTypeOf('string');
|
expect(npmToken).toBeTypeOf('string');
|
||||||
const serverSetup = await createHttpServer(registry, registryPort);
|
const serverSetup = await createHttpServer(registry, registryPort);
|
||||||
@@ -235,8 +239,8 @@ tap.test('NPM CLI: should setup registry and HTTP server', async () => {
|
|||||||
expect(server).toBeDefined();
|
expect(server).toBeDefined();
|
||||||
expect(registryUrl).toEqual(`http://localhost:${registryPort}`);
|
expect(registryUrl).toEqual(`http://localhost:${registryPort}`);
|
||||||
|
|
||||||
// Setup test directory
|
// Setup test directory — use /tmp to isolate from project tree
|
||||||
testDir = path.join(process.cwd(), '.nogit', 'test-npm-cli');
|
testDir = path.join('/tmp', 'smartregistry-test-npm-cli');
|
||||||
cleanupTestDir(testDir);
|
cleanupTestDir(testDir);
|
||||||
fs.mkdirSync(testDir, { recursive: true });
|
fs.mkdirSync(testDir, { recursive: true });
|
||||||
|
|
||||||
@@ -285,20 +289,16 @@ tap.test('NPM CLI: should install published package', async () => {
|
|||||||
const installDir = path.join(testDir, 'install-test');
|
const installDir = path.join(testDir, 'install-test');
|
||||||
fs.mkdirSync(installDir, { recursive: true });
|
fs.mkdirSync(installDir, { recursive: true });
|
||||||
|
|
||||||
// Create package.json for installation
|
// Create a minimal package.json for install target
|
||||||
const packageJson = {
|
|
||||||
name: 'install-test',
|
|
||||||
version: '1.0.0',
|
|
||||||
dependencies: {
|
|
||||||
[packageName]: '1.0.0',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(installDir, 'package.json'),
|
path.join(installDir, 'package.json'),
|
||||||
JSON.stringify(packageJson, null, 2),
|
JSON.stringify({ name: 'install-test', version: '1.0.0', dependencies: { [packageName]: '1.0.0' } }),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
|
// Copy .npmrc
|
||||||
|
if (npmrcPath && fs.existsSync(npmrcPath)) {
|
||||||
|
fs.copyFileSync(npmrcPath, path.join(installDir, '.npmrc'));
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runNpmCommand('npm install', installDir);
|
const result = await runNpmCommand('npm install', installDir);
|
||||||
console.log('npm install output:', result.stdout);
|
console.log('npm install output:', result.stdout);
|
||||||
@@ -307,17 +307,8 @@ tap.test('NPM CLI: should install published package', async () => {
|
|||||||
expect(result.exitCode).toEqual(0);
|
expect(result.exitCode).toEqual(0);
|
||||||
|
|
||||||
// Verify package was installed
|
// Verify package was installed
|
||||||
const nodeModulesPath = path.join(installDir, 'node_modules', packageName);
|
const installed = fs.existsSync(path.join(installDir, 'node_modules', packageName, 'package.json'));
|
||||||
expect(fs.existsSync(nodeModulesPath)).toEqual(true);
|
expect(installed).toEqual(true);
|
||||||
expect(fs.existsSync(path.join(nodeModulesPath, 'package.json'))).toEqual(true);
|
|
||||||
expect(fs.existsSync(path.join(nodeModulesPath, 'index.js'))).toEqual(true);
|
|
||||||
|
|
||||||
// Verify package contents
|
|
||||||
const installedPackageJson = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(nodeModulesPath, 'package.json'), 'utf-8')
|
|
||||||
);
|
|
||||||
expect(installedPackageJson.name).toEqual(packageName);
|
|
||||||
expect(installedPackageJson.version).toEqual('1.0.0');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('NPM CLI: should publish second version', async () => {
|
tap.test('NPM CLI: should publish second version', async () => {
|
||||||
@@ -369,17 +360,14 @@ tap.test('NPM CLI: should fail to publish without auth', async () => {
|
|||||||
const version = '1.0.0';
|
const version = '1.0.0';
|
||||||
const packageDir = createTestPackage(packageName, version, testDir);
|
const packageDir = createTestPackage(packageName, version, testDir);
|
||||||
|
|
||||||
// Temporarily remove .npmrc
|
// Temporarily remove .npmrc (write one without auth)
|
||||||
const npmrcBackup = fs.readFileSync(npmrcPath, 'utf-8');
|
const noAuthNpmrc = path.join(packageDir, '.npmrc');
|
||||||
fs.writeFileSync(npmrcPath, 'registry=' + registryUrl + '/npm/\n', 'utf-8');
|
fs.writeFileSync(noAuthNpmrc, `registry=${registryUrl}/npm/\n`, 'utf-8');
|
||||||
|
|
||||||
const result = await runNpmCommand('npm publish', packageDir);
|
const result = await runNpmCommand('npm publish', packageDir);
|
||||||
console.log('npm publish unauth output:', result.stdout);
|
console.log('npm publish unauth output:', result.stdout);
|
||||||
console.log('npm publish unauth stderr:', result.stderr);
|
console.log('npm publish unauth stderr:', result.stderr);
|
||||||
|
|
||||||
// Restore .npmrc
|
|
||||||
fs.writeFileSync(npmrcPath, npmrcBackup, 'utf-8');
|
|
||||||
|
|
||||||
// Should fail with auth error
|
// Should fail with auth error
|
||||||
expect(result.exitCode).not.toEqual(0);
|
expect(result.exitCode).not.toEqual(0);
|
||||||
});
|
});
|
||||||
@@ -393,14 +381,7 @@ tap.postTask('cleanup npm cli tests', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup test directory
|
// Cleanup test directory
|
||||||
if (testDir) {
|
|
||||||
cleanupTestDir(testDir);
|
cleanupTestDir(testDir);
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy registry
|
|
||||||
if (registry) {
|
|
||||||
registry.destroy();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
+45
-1
@@ -1,7 +1,7 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import { SmartRegistry } from '../ts/index.js';
|
import { SmartRegistry } from '../ts/index.js';
|
||||||
import { streamToBuffer, streamToJson } from '../ts/core/helpers.stream.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 registry: SmartRegistry;
|
||||||
let npmToken: string;
|
let npmToken: string;
|
||||||
@@ -137,6 +137,50 @@ tap.test('NPM: should publish a new version of the package', async () => {
|
|||||||
expect(getBody.versions).toHaveProperty(newVersion);
|
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 () => {
|
tap.test('NPM: should get dist-tags (GET /-/package/{pkg}/dist-tags)', async () => {
|
||||||
const response = await registry.handleRequest({
|
const response = await registry.handleRequest({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -148,11 +148,16 @@ async function runGemCommand(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
includeAuth: boolean = true
|
includeAuth: boolean = true
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||||
|
// When not including auth, use a temp HOME without credentials
|
||||||
|
const effectiveHome = includeAuth ? gemHome : path.join(gemHome, 'noauth');
|
||||||
|
if (!includeAuth) {
|
||||||
|
fs.mkdirSync(effectiveHome, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare environment variables
|
// Prepare environment variables
|
||||||
const envVars = [
|
const envVars = [
|
||||||
`HOME="${gemHome}"`,
|
`HOME="${effectiveHome}"`,
|
||||||
`GEM_HOME="${gemHome}"`,
|
`GEM_HOME="${gemHome}"`,
|
||||||
includeAuth ? '' : 'RUBYGEMS_API_KEY=""',
|
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
// Build command with cd to correct directory and environment variables
|
// Build command with cd to correct directory and environment variables
|
||||||
@@ -360,31 +365,33 @@ tap.test('RubyGems CLI: should unyank a version', async () => {
|
|||||||
const gemName = 'test-gem-cli';
|
const gemName = 'test-gem-cli';
|
||||||
const version = '1.0.0';
|
const version = '1.0.0';
|
||||||
|
|
||||||
const result = await runGemCommand(
|
// Use PUT /api/v1/gems/unyank via HTTP API (gem yank --undo removed in Ruby 4.0)
|
||||||
`gem yank ${gemName} -v ${version} --undo --host ${registryUrl}/rubygems`,
|
const response = await fetch(
|
||||||
testDir
|
`${registryUrl}/rubygems/api/v1/gems/unyank?gem_name=${gemName}&version=${version}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': rubygemsToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
console.log('gem unyank output:', result.stdout);
|
console.log('gem unyank status:', response.status);
|
||||||
console.log('gem unyank stderr:', result.stderr);
|
|
||||||
|
|
||||||
expect(result.exitCode).toEqual(0);
|
expect(response.status).toEqual(200);
|
||||||
|
|
||||||
// Verify version is not yanked in /versions file
|
// Verify version is not yanked in /versions file
|
||||||
const response = await fetch(`${registryUrl}/rubygems/versions`);
|
const versionsResponse = await fetch(`${registryUrl}/rubygems/versions`);
|
||||||
const versionsData = await response.text();
|
const versionsData = await versionsResponse.text();
|
||||||
console.log('Versions after unyank:', versionsData);
|
console.log('Versions after unyank:', versionsData);
|
||||||
|
|
||||||
// Should not have '-' prefix anymore (or have both without prefix)
|
// Should not have '-' prefix anymore
|
||||||
// Check that we have the version without yank marker
|
|
||||||
const lines = versionsData.trim().split('\n');
|
const lines = versionsData.trim().split('\n');
|
||||||
const gemLine = lines.find(line => line.startsWith(gemName));
|
const gemLine = lines.find(line => line.startsWith(gemName));
|
||||||
|
|
||||||
if (gemLine) {
|
if (gemLine) {
|
||||||
// Parse format: "gemname version[,version...] md5"
|
|
||||||
const parts = gemLine.split(' ');
|
const parts = gemLine.split(' ');
|
||||||
const versions = parts[1];
|
const versions = parts[1];
|
||||||
|
|
||||||
// Should have 1.0.0 without '-' prefix
|
|
||||||
expect(versions).toContain('1.0.0');
|
expect(versions).toContain('1.0.0');
|
||||||
expect(versions).not.toContain('-1.0.0');
|
expect(versions).not.toContain('-1.0.0');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,13 +324,9 @@ tap.test('RubyGems: should retrieve versions JSON (GET /rubygems/api/v1/versions
|
|||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
expect(response.headers['Content-Type']).toEqual('application/json');
|
expect(response.headers['Content-Type']).toEqual('application/json');
|
||||||
const json = await streamToJson(response.body);
|
const json = await streamToJson(response.body);
|
||||||
expect(json).toBeTypeOf('object');
|
expect(json).toBeInstanceOf(Array);
|
||||||
|
expect(json.length).toBeGreaterThan(0);
|
||||||
expect(json).toHaveProperty('name');
|
expect(json[0]).toHaveProperty('number');
|
||||||
expect(json.name).toEqual(testGemName);
|
|
||||||
expect(json).toHaveProperty('versions');
|
|
||||||
expect(json.versions).toBeTypeOf('object');
|
|
||||||
expect(json.versions.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('RubyGems: should retrieve dependencies JSON (GET /rubygems/api/v1/dependencies)', async () => {
|
tap.test('RubyGems: should retrieve dependencies JSON (GET /rubygems/api/v1/dependencies)', async () => {
|
||||||
|
|||||||
+142
-1
@@ -3,7 +3,14 @@ import * as qenv from '@push.rocks/qenv';
|
|||||||
import { RegistryStorage } from '../ts/core/classes.registrystorage.js';
|
import { RegistryStorage } from '../ts/core/classes.registrystorage.js';
|
||||||
import type { IStorageConfig } from '../ts/core/interfaces.core.js';
|
import type { IStorageConfig } from '../ts/core/interfaces.core.js';
|
||||||
import type { IStorageHooks, IStorageHookContext } from '../ts/core/interfaces.storage.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');
|
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'));
|
await errorStorage.putObject('test/after-error.txt', Buffer.from('ok'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('withContext: should isolate concurrent async operations', async () => {
|
||||||
|
const tracker = createTrackingHooks();
|
||||||
|
|
||||||
|
const concurrentStorage = new RegistryStorage(storageConfig, tracker.hooks);
|
||||||
|
await concurrentStorage.init();
|
||||||
|
|
||||||
|
const bucket = (concurrentStorage as any).bucket;
|
||||||
|
const originalFastPut = bucket.fastPut.bind(bucket);
|
||||||
|
const pendingWrites: Array<() => void> = [];
|
||||||
|
let startedWrites = 0;
|
||||||
|
let waitingWrites = 0;
|
||||||
|
let startedResolve: () => void;
|
||||||
|
let waitingResolve: () => void;
|
||||||
|
const bothWritesStarted = new Promise<void>((resolve) => {
|
||||||
|
startedResolve = resolve;
|
||||||
|
});
|
||||||
|
const bothWritesWaiting = new Promise<void>((resolve) => {
|
||||||
|
waitingResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
bucket.fastPut = async (options: any) => {
|
||||||
|
startedWrites += 1;
|
||||||
|
if (startedWrites === 2) {
|
||||||
|
startedResolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
await bothWritesStarted;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
pendingWrites.push(resolve);
|
||||||
|
waitingWrites += 1;
|
||||||
|
if (waitingWrites === 2) {
|
||||||
|
waitingResolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return originalFastPut(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const opA = concurrentStorage.withContext(
|
||||||
|
{
|
||||||
|
protocol: 'npm',
|
||||||
|
actor: { userId: 'user-a' },
|
||||||
|
metadata: { packageName: 'package-a' },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await concurrentStorage.putObject('test/concurrent-a.txt', Buffer.from('a'));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const opB = concurrentStorage.withContext(
|
||||||
|
{
|
||||||
|
protocol: 'npm',
|
||||||
|
actor: { userId: 'user-b' },
|
||||||
|
metadata: { packageName: 'package-b' },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await concurrentStorage.putObject('test/concurrent-b.txt', Buffer.from('b'));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await bothWritesWaiting;
|
||||||
|
|
||||||
|
pendingWrites[0]!();
|
||||||
|
pendingWrites[1]!();
|
||||||
|
|
||||||
|
await Promise.all([opA, opB]);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} finally {
|
||||||
|
bucket.fastPut = originalFastPut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterPutCalls = tracker.calls.filter(
|
||||||
|
(call) => call.method === 'afterPut' && call.context.key.startsWith('test/concurrent-')
|
||||||
|
);
|
||||||
|
expect(afterPutCalls.length).toEqual(2);
|
||||||
|
|
||||||
|
const callByKey = new Map(afterPutCalls.map((call) => [call.context.key, call]));
|
||||||
|
expect(callByKey.get('test/concurrent-a.txt')?.context.actor?.userId).toEqual('user-a');
|
||||||
|
expect(callByKey.get('test/concurrent-a.txt')?.context.metadata?.packageName).toEqual('package-a');
|
||||||
|
expect(callByKey.get('test/concurrent-b.txt')?.context.actor?.userId).toEqual('user-b');
|
||||||
|
expect(callByKey.get('test/concurrent-b.txt')?.context.metadata?.packageName).toEqual('package-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('request hooks: should receive context during real npm publish requests', async () => {
|
||||||
|
const tracker = createTrackingHooks();
|
||||||
|
const registry = await createTestRegistry({ storageHooks: tracker.hooks });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await createTestTokens(registry);
|
||||||
|
const packageName = `hooked-package-${generateTestRunId()}`;
|
||||||
|
const version = '1.0.0';
|
||||||
|
const tarball = Buffer.from('hooked tarball data', 'utf-8');
|
||||||
|
const packument = createTestPackument(packageName, version, tarball);
|
||||||
|
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/npm/${packageName}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.npmToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
body: packument,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(201);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const npmWrites = tracker.calls.filter(
|
||||||
|
(call) => call.method === 'beforePut' && call.context.metadata?.packageName === packageName
|
||||||
|
);
|
||||||
|
expect(npmWrites.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const packumentWrite = npmWrites.find(
|
||||||
|
(call) => call.context.key === `npm/packages/${packageName}/index.json`
|
||||||
|
);
|
||||||
|
expect(packumentWrite).toBeTruthy();
|
||||||
|
expect(packumentWrite!.context.protocol).toEqual('npm');
|
||||||
|
expect(packumentWrite!.context.actor?.userId).toEqual(tokens.userId);
|
||||||
|
expect(packumentWrite!.context.metadata?.packageName).toEqual(packageName);
|
||||||
|
|
||||||
|
const tarballWrite = npmWrites.find(
|
||||||
|
(call) => call.context.key.endsWith(`-${version}.tgz`)
|
||||||
|
);
|
||||||
|
expect(tarballWrite).toBeTruthy();
|
||||||
|
expect(tarballWrite!.context.metadata?.packageName).toEqual(packageName);
|
||||||
|
expect(tarballWrite!.context.metadata?.version).toEqual(version);
|
||||||
|
} finally {
|
||||||
|
registry.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Graceful Degradation Tests
|
// Graceful Degradation Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartregistry',
|
name: '@push.rocks/smartregistry',
|
||||||
version: '2.8.0',
|
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'
|
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,18 +43,7 @@ export class CargoRegistry extends BaseRegistry {
|
|||||||
this.registryUrl = registryUrl;
|
this.registryUrl = registryUrl;
|
||||||
this.upstreamProvider = upstreamProvider || null;
|
this.upstreamProvider = upstreamProvider || null;
|
||||||
|
|
||||||
// Initialize logger
|
this.logger = this.createProtocolLogger('cargo-registry', 'cargo');
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,16 +99,10 @@ export class CargoRegistry extends BaseRegistry {
|
|||||||
const path = context.path.replace(this.basePath, '');
|
const path = context.path.replace(this.basePath, '');
|
||||||
|
|
||||||
// Extract token (Cargo uses Authorization header WITHOUT "Bearer" prefix)
|
// 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;
|
const token = authHeader ? await this.authManager.validateToken(authHeader, 'cargo') : null;
|
||||||
|
|
||||||
// Build actor from context and validated token
|
const actor: IRequestActor = this.buildRequestActor(context, 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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
||||||
method: context.method,
|
method: context.method,
|
||||||
@@ -127,6 +110,7 @@ export class CargoRegistry extends BaseRegistry {
|
|||||||
hasAuth: !!token
|
hasAuth: !!token
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.storage.withContext({ protocol: 'cargo', actor }, async () => {
|
||||||
// Config endpoint (required for sparse protocol)
|
// Config endpoint (required for sparse protocol)
|
||||||
if (path === '/config.json') {
|
if (path === '/config.json') {
|
||||||
return this.handleConfigJson();
|
return this.handleConfigJson();
|
||||||
@@ -139,6 +123,7 @@ export class CargoRegistry extends BaseRegistry {
|
|||||||
|
|
||||||
// Index files (sparse protocol)
|
// Index files (sparse protocol)
|
||||||
return this.handleIndexRequest(path, actor);
|
return this.handleIndexRequest(path, actor);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+153
-161
@@ -1,7 +1,13 @@
|
|||||||
import { RegistryStorage } from './core/classes.registrystorage.js';
|
import { RegistryStorage } from './core/classes.registrystorage.js';
|
||||||
import { AuthManager } from './core/classes.authmanager.js';
|
import { AuthManager } from './core/classes.authmanager.js';
|
||||||
import { BaseRegistry } from './core/classes.baseregistry.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 { toReadableStream } from './core/helpers.stream.js';
|
||||||
import { OciRegistry } from './oci/classes.ociregistry.js';
|
import { OciRegistry } from './oci/classes.ociregistry.js';
|
||||||
import { NpmRegistry } from './npm/classes.npmregistry.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 { PypiRegistry } from './pypi/classes.pypiregistry.js';
|
||||||
import { RubyGemsRegistry } from './rubygems/classes.rubygemsregistry.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.
|
* Main registry orchestrator.
|
||||||
* Routes requests to appropriate protocol handlers (OCI, NPM, Maven, Cargo, Composer, PyPI, or RubyGems).
|
* 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 {
|
export class SmartRegistry {
|
||||||
private storage: RegistryStorage;
|
private storage: RegistryStorage;
|
||||||
private authManager: AuthManager;
|
private authManager: AuthManager;
|
||||||
private registries: Map<string, BaseRegistry> = new Map();
|
private registries: Map<TRegistryProtocol, BaseRegistry> = new Map();
|
||||||
private config: IRegistryConfig;
|
private config: IRegistryConfig;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
||||||
@@ -75,112 +204,20 @@ export class SmartRegistry {
|
|||||||
// Initialize auth manager
|
// Initialize auth manager
|
||||||
await this.authManager.init();
|
await this.authManager.init();
|
||||||
|
|
||||||
// Initialize OCI registry if enabled
|
for (const descriptor of registryDescriptors) {
|
||||||
if (this.config.oci?.enabled) {
|
const protocolConfig = descriptor.getConfig(this.config);
|
||||||
const ociBasePath = this.config.oci.basePath ?? '/oci';
|
if (!protocolConfig?.enabled) {
|
||||||
const ociTokens = this.config.auth.ociTokens?.enabled ? {
|
continue;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize NPM registry if enabled
|
const registry = descriptor.create({
|
||||||
if (this.config.npm?.enabled) {
|
storage: this.storage,
|
||||||
const npmBasePath = this.config.npm.basePath ?? '/npm';
|
authManager: this.authManager,
|
||||||
const registryUrl = this.config.npm.registryUrl ?? `http://localhost:5000${npmBasePath}`;
|
config: this.config,
|
||||||
const npmRegistry = new NpmRegistry(
|
protocolConfig,
|
||||||
this.storage,
|
});
|
||||||
this.authManager,
|
await registry.init();
|
||||||
npmBasePath,
|
this.registries.set(descriptor.protocol, registry);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@@ -194,62 +231,19 @@ export class SmartRegistry {
|
|||||||
const path = context.path;
|
const path = context.path;
|
||||||
let response: IResponse | undefined;
|
let response: IResponse | undefined;
|
||||||
|
|
||||||
// Route to OCI registry
|
for (const descriptor of registryDescriptors) {
|
||||||
if (!response && this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) {
|
if (response) {
|
||||||
const ociRegistry = this.registries.get('oci');
|
break;
|
||||||
if (ociRegistry) {
|
|
||||||
response = await ociRegistry.handleRequest(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to NPM registry
|
const protocolConfig = descriptor.getConfig(this.config);
|
||||||
if (!response && this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) {
|
if (!protocolConfig?.enabled || !descriptor.matchesPath(this.config, path)) {
|
||||||
const npmRegistry = this.registries.get('npm');
|
continue;
|
||||||
if (npmRegistry) {
|
|
||||||
response = await npmRegistry.handleRequest(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to Maven registry
|
const registry = this.registries.get(descriptor.protocol);
|
||||||
if (!response && this.config.maven?.enabled && path.startsWith(this.config.maven.basePath)) {
|
if (registry) {
|
||||||
const mavenRegistry = this.registries.get('maven');
|
response = await registry.handleRequest(context);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,9 +303,7 @@ export class SmartRegistry {
|
|||||||
*/
|
*/
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
for (const registry of this.registries.values()) {
|
for (const registry of this.registries.values()) {
|
||||||
if (typeof (registry as any).destroy === 'function') {
|
registry.destroy();
|
||||||
(registry as any).destroy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,18 +104,18 @@ export class ComposerRegistry extends BaseRegistry {
|
|||||||
const path = context.path.replace(this.basePath, '');
|
const path = context.path.replace(this.basePath, '');
|
||||||
|
|
||||||
// Extract token from Authorization header
|
// Extract token from Authorization header
|
||||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
const authHeader = this.getAuthorizationHeader(context);
|
||||||
let token: IAuthToken | null = null;
|
let token: IAuthToken | null = null;
|
||||||
|
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
if (authHeader.startsWith('Bearer ')) {
|
const tokenString = this.extractBearerToken(authHeader);
|
||||||
const tokenString = authHeader.replace(/^Bearer\s+/i, '');
|
if (tokenString) {
|
||||||
token = await this.authManager.validateToken(tokenString, 'composer');
|
token = await this.authManager.validateToken(tokenString, 'composer');
|
||||||
} else if (authHeader.startsWith('Basic ')) {
|
} else {
|
||||||
// Handle HTTP Basic Auth
|
// Handle HTTP Basic Auth
|
||||||
const credentials = Buffer.from(authHeader.replace(/^Basic\s+/i, ''), 'base64').toString('utf-8');
|
const basicCredentials = this.parseBasicAuthHeader(authHeader);
|
||||||
const [username, password] = credentials.split(':');
|
if (basicCredentials) {
|
||||||
const userId = await this.authManager.authenticate({ username, password });
|
const userId = await this.authManager.authenticate(basicCredentials);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
// Create temporary token for this request
|
// Create temporary token for this request
|
||||||
token = {
|
token = {
|
||||||
@@ -127,15 +127,11 @@ export class ComposerRegistry extends BaseRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build actor from context and validated token
|
const actor: IRequestActor = this.buildRequestActor(context, 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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return this.storage.withContext({ protocol: 'composer', actor }, async () => {
|
||||||
// Root packages.json
|
// Root packages.json
|
||||||
if (path === '/packages.json' || path === '' || path === '/') {
|
if (path === '/packages.json' || path === '' || path === '/') {
|
||||||
return this.handlePackagesJson();
|
return this.handlePackagesJson();
|
||||||
@@ -187,6 +183,7 @@ export class ComposerRegistry extends BaseRegistry {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: { status: 'error', message: 'Not found' },
|
body: { status: 'error', message: 'Not found' },
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async checkPermission(
|
protected async checkPermission(
|
||||||
|
|||||||
@@ -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
|
* Abstract base class for all registry protocol implementations
|
||||||
*/
|
*/
|
||||||
export abstract class BaseRegistry {
|
export abstract class BaseRegistry {
|
||||||
|
protected getHeader(contextOrHeaders: IRequestContext | Record<string, string>, name: string): string | undefined {
|
||||||
|
const headers = 'headers' in contextOrHeaders ? contextOrHeaders.headers : contextOrHeaders;
|
||||||
|
if (headers[name] !== undefined) {
|
||||||
|
return headers[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
for (const [headerName, value] of Object.entries(headers)) {
|
||||||
|
if (headerName.toLowerCase() === lowerName) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAuthorizationHeader(context: IRequestContext): string | undefined {
|
||||||
|
return this.getHeader(context, 'authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getClientIp(context: IRequestContext): string | undefined {
|
||||||
|
const forwardedFor = this.getHeader(context, 'x-forwarded-for');
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor.split(',')[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getHeader(context, 'x-real-ip');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getUserAgent(context: IRequestContext): string | undefined {
|
||||||
|
return this.getHeader(context, 'user-agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected extractBearerToken(contextOrHeader: IRequestContext | string | undefined): string | null {
|
||||||
|
const authHeader = typeof contextOrHeader === 'string'
|
||||||
|
? contextOrHeader
|
||||||
|
: contextOrHeader
|
||||||
|
? this.getAuthorizationHeader(contextOrHeader)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!authHeader || !/^Bearer\s+/i.test(authHeader)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authHeader.replace(/^Bearer\s+/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseBasicAuthHeader(authHeader: string | undefined): { username: string; password: string } | null {
|
||||||
|
if (!authHeader || !/^Basic\s+/i.test(authHeader)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = authHeader.replace(/^Basic\s+/i, '');
|
||||||
|
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
|
||||||
|
const separatorIndex = decoded.indexOf(':');
|
||||||
|
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return {
|
||||||
|
username: decoded,
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: decoded.substring(0, separatorIndex),
|
||||||
|
password: decoded.substring(separatorIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildRequestActor(context: IRequestContext, token: IAuthToken | null): IRequestActor {
|
||||||
|
const actor: IRequestActor = {
|
||||||
|
...(context.actor ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token?.userId) {
|
||||||
|
actor.userId = token.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = this.getClientIp(context);
|
||||||
|
if (ip) {
|
||||||
|
actor.ip = ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = this.getUserAgent(context);
|
||||||
|
if (userAgent) {
|
||||||
|
actor.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createProtocolLogger(
|
||||||
|
containerName: string,
|
||||||
|
zone: string
|
||||||
|
): plugins.smartlog.Smartlog {
|
||||||
|
const logger = new plugins.smartlog.Smartlog({
|
||||||
|
logContext: {
|
||||||
|
company: 'push.rocks',
|
||||||
|
companyunit: 'smartregistry',
|
||||||
|
containerName,
|
||||||
|
environment: (process.env.NODE_ENV as any) || 'development',
|
||||||
|
runtime: 'node',
|
||||||
|
zone,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.enableConsole();
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the registry
|
* Initialize the registry
|
||||||
*/
|
*/
|
||||||
abstract init(): Promise<void>;
|
abstract init(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up timers, connections, and other registry resources.
|
||||||
|
*/
|
||||||
|
public destroy(): void {
|
||||||
|
// Default no-op for registries without persistent resources.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an incoming HTTP request
|
* Handle an incoming HTTP request
|
||||||
* @param context - Request context
|
* @param context - Request context
|
||||||
|
|||||||
+158
-312
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||||
|
}
|
||||||
@@ -105,24 +105,23 @@ export class MavenRegistry extends BaseRegistry {
|
|||||||
// Remove base path from URL
|
// Remove base path from URL
|
||||||
const path = context.path.replace(this.basePath, '');
|
const path = context.path.replace(this.basePath, '');
|
||||||
|
|
||||||
// Extract token from Authorization header
|
const authHeader = this.getAuthorizationHeader(context);
|
||||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
|
||||||
let token: IAuthToken | null = null;
|
let token: IAuthToken | null = null;
|
||||||
|
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
const tokenString = authHeader.replace(/^(Bearer|Basic)\s+/i, '');
|
const basicCredentials = this.parseBasicAuthHeader(authHeader);
|
||||||
// For now, try to validate as Maven token (reuse npm token type)
|
if (basicCredentials) {
|
||||||
token = await this.authManager.validateToken(tokenString, 'maven');
|
// Maven sends Basic Auth: base64(username:password) — extract the password as token
|
||||||
|
token = await this.authManager.validateToken(basicCredentials.password, 'maven');
|
||||||
|
} else {
|
||||||
|
const tokenString = this.extractBearerToken(authHeader);
|
||||||
|
token = tokenString ? await this.authManager.validateToken(tokenString, 'maven') : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build actor from context and validated token
|
const actor: IRequestActor = this.buildRequestActor(context, 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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return this.storage.withContext({ protocol: 'maven', actor }, async () => {
|
||||||
// Parse path to determine request type
|
// Parse path to determine request type
|
||||||
const coordinate = pathToGAV(path);
|
const coordinate = pathToGAV(path);
|
||||||
|
|
||||||
@@ -147,6 +146,7 @@ export class MavenRegistry extends BaseRegistry {
|
|||||||
|
|
||||||
// Handle artifact requests (JAR, POM, WAR, etc.)
|
// Handle artifact requests (JAR, POM, WAR, etc.)
|
||||||
return this.handleArtifactRequest(context.method, coordinate, token, context.body, actor);
|
return this.handleArtifactRequest(context.method, coordinate, token, context.body, actor);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async checkPermission(
|
protected async checkPermission(
|
||||||
@@ -240,9 +240,19 @@ export class MavenRegistry extends BaseRegistry {
|
|||||||
return this.getChecksum(groupId, artifactId, version, coordinate, path);
|
return this.getChecksum(groupId, artifactId, version, coordinate, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accept PUT silently — Maven deploy-plugin uploads checksums alongside artifacts,
|
||||||
|
// but our registry auto-generates them, so we just acknowledge the upload
|
||||||
|
if (method === 'PUT') {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
body: { status: 'ok' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { 'Allow': 'GET, HEAD' },
|
headers: { 'Allow': 'GET, HEAD, PUT' },
|
||||||
body: { error: 'METHOD_NOT_ALLOWED', message: 'Checksums are auto-generated' },
|
body: { error: 'METHOD_NOT_ALLOWED', message: 'Checksums are auto-generated' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -275,9 +285,19 @@ export class MavenRegistry extends BaseRegistry {
|
|||||||
return this.getMetadata(groupId, artifactId, actor);
|
return this.getMetadata(groupId, artifactId, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accept PUT silently — Maven deploy-plugin uploads maven-metadata.xml,
|
||||||
|
// but our registry auto-generates it, so we just acknowledge the upload
|
||||||
|
if (method === 'PUT') {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
body: { status: 'ok' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { 'Allow': 'GET' },
|
headers: { 'Allow': 'GET, PUT' },
|
||||||
body: { error: 'METHOD_NOT_ALLOWED', message: 'Metadata is auto-generated' },
|
body: { error: 'METHOD_NOT_ALLOWED', message: 'Metadata is auto-generated' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-175
@@ -7,7 +7,6 @@ import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
|
|||||||
import { NpmUpstream } from './classes.npmupstream.js';
|
import { NpmUpstream } from './classes.npmupstream.js';
|
||||||
import type {
|
import type {
|
||||||
IPackument,
|
IPackument,
|
||||||
INpmVersion,
|
|
||||||
IPublishRequest,
|
IPublishRequest,
|
||||||
ISearchResponse,
|
ISearchResponse,
|
||||||
ISearchResult,
|
ISearchResult,
|
||||||
@@ -16,6 +15,13 @@ import type {
|
|||||||
IUserAuthRequest,
|
IUserAuthRequest,
|
||||||
INpmError,
|
INpmError,
|
||||||
} from './interfaces.npm.js';
|
} from './interfaces.npm.js';
|
||||||
|
import {
|
||||||
|
createNewPackument,
|
||||||
|
getAttachmentForVersion,
|
||||||
|
preparePublishedVersion,
|
||||||
|
recordPublishedVersion,
|
||||||
|
} from './helpers.npmpublish.js';
|
||||||
|
import { parseNpmRequestRoute } from './helpers.npmroutes.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NPM Registry implementation
|
* NPM Registry implementation
|
||||||
@@ -43,18 +49,7 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
this.registryUrl = registryUrl;
|
this.registryUrl = registryUrl;
|
||||||
this.upstreamProvider = upstreamProvider || null;
|
this.upstreamProvider = upstreamProvider || null;
|
||||||
|
|
||||||
// Initialize logger
|
this.logger = this.createProtocolLogger('npm-registry', 'npm');
|
||||||
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();
|
|
||||||
|
|
||||||
if (upstreamProvider) {
|
if (upstreamProvider) {
|
||||||
this.logger.log('info', 'NPM upstream provider configured');
|
this.logger.log('info', 'NPM upstream provider configured');
|
||||||
@@ -112,18 +107,10 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
||||||
const path = context.path.replace(this.basePath, '');
|
const path = context.path.replace(this.basePath, '');
|
||||||
|
|
||||||
// Extract token from Authorization header
|
const tokenString = this.extractBearerToken(context);
|
||||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
|
||||||
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
|
|
||||||
const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null;
|
const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null;
|
||||||
|
|
||||||
// Build actor context for upstream resolution
|
const actor: IRequestActor = this.buildRequestActor(context, token);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
||||||
method: context.method,
|
method: context.method,
|
||||||
@@ -131,73 +118,9 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
hasAuth: !!token
|
hasAuth: !!token
|
||||||
});
|
});
|
||||||
|
|
||||||
// Registry root
|
return this.storage.withContext({ protocol: 'npm', actor }, async () => {
|
||||||
if (path === '/' || path === '') {
|
const route = parseNpmRequestRoute(path, context.method);
|
||||||
return this.handleRegistryInfo();
|
if (!route) {
|
||||||
}
|
|
||||||
|
|
||||||
// Search: /-/v1/search
|
|
||||||
if (path.startsWith('/-/v1/search')) {
|
|
||||||
return this.handleSearch(context.query);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
return {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -205,6 +128,67 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected async checkPermission(
|
protected async checkPermission(
|
||||||
token: IAuthToken | null,
|
token: IAuthToken | null,
|
||||||
resource: string,
|
resource: string,
|
||||||
@@ -268,30 +252,7 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
query: Record<string, string>,
|
query: Record<string, string>,
|
||||||
actor?: IRequestActor
|
actor?: IRequestActor
|
||||||
): Promise<IResponse> {
|
): Promise<IResponse> {
|
||||||
let packument = await this.storage.getNpmPackument(packageName);
|
const packument = await this.getLocalOrUpstreamPackument(packageName, actor, 'getPackument');
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!packument) {
|
if (!packument) {
|
||||||
return {
|
return {
|
||||||
@@ -333,24 +294,12 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
actor?: IRequestActor
|
actor?: IRequestActor
|
||||||
): Promise<IResponse> {
|
): Promise<IResponse> {
|
||||||
this.logger.log('debug', 'handlePackageVersion', { packageName, version });
|
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 });
|
this.logger.log('debug', 'handlePackageVersion packument', { found: !!packument });
|
||||||
if (packument) {
|
if (packument) {
|
||||||
this.logger.log('debug', 'handlePackageVersion versions', { versions: Object.keys(packument.versions || {}) });
|
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) {
|
if (!packument) {
|
||||||
return {
|
return {
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -424,19 +373,7 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
const isNew = !packument;
|
const isNew = !packument;
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
packument = {
|
packument = createNewPackument(packageName, body, new Date().toISOString());
|
||||||
_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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each new version
|
// Process each new version
|
||||||
@@ -450,12 +387,8 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find attachment for this version
|
const attachment = getAttachmentForVersion(body, version);
|
||||||
const attachmentKey = Object.keys(body._attachments).find(key =>
|
if (!attachment) {
|
||||||
key.includes(version)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!attachmentKey) {
|
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -463,38 +396,24 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = body._attachments[attachmentKey];
|
const preparedVersion = preparePublishedVersion({
|
||||||
|
packageName,
|
||||||
// Decode base64 tarball
|
version,
|
||||||
const tarballBuffer = Buffer.from(attachment.data, 'base64');
|
versionData,
|
||||||
|
attachment,
|
||||||
// Calculate shasum
|
registryUrl: this.registryUrl,
|
||||||
const crypto = await import('crypto');
|
userId: token?.userId,
|
||||||
const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex');
|
});
|
||||||
const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`;
|
|
||||||
|
|
||||||
// Store tarball
|
// 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
|
recordPublishedVersion(packument, version, preparedVersion.versionData, new Date().toISOString());
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update dist-tags
|
// Update dist-tags
|
||||||
@@ -632,6 +551,11 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
|
|
||||||
const version = versionMatch[1];
|
const version = versionMatch[1];
|
||||||
|
|
||||||
|
return this.withPackageVersionContext(
|
||||||
|
packageName,
|
||||||
|
version,
|
||||||
|
actor,
|
||||||
|
async (): Promise<IResponse> => {
|
||||||
// Try local storage first (streaming)
|
// Try local storage first (streaming)
|
||||||
const streamResult = await this.storage.getNpmTarballStream(packageName, version);
|
const streamResult = await this.storage.getNpmTarballStream(packageName, version);
|
||||||
if (streamResult) {
|
if (streamResult) {
|
||||||
@@ -683,6 +607,64 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
body: tarball,
|
body: tarball,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withPackageContext<T>(
|
||||||
|
packageName: string,
|
||||||
|
actor: IRequestActor | undefined,
|
||||||
|
fn: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.storage.withContext(
|
||||||
|
{ protocol: 'npm', actor, metadata: { packageName } },
|
||||||
|
fn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLocalOrUpstreamPackument(
|
||||||
|
packageName: string,
|
||||||
|
actor: IRequestActor | undefined,
|
||||||
|
logPrefix: string
|
||||||
|
): Promise<IPackument | null> {
|
||||||
|
const localPackument = await this.storage.getNpmPackument(packageName);
|
||||||
|
this.logger.log('debug', `${logPrefix}: ${packageName}`, {
|
||||||
|
packageName,
|
||||||
|
found: !!localPackument,
|
||||||
|
versions: localPackument ? Object.keys(localPackument.versions).length : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (localPackument) {
|
||||||
|
return localPackument;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
|
||||||
|
if (!upstream) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('debug', `${logPrefix}: fetching from upstream`, { packageName });
|
||||||
|
const upstreamPackument = await upstream.fetchPackument(packageName);
|
||||||
|
if (upstreamPackument) {
|
||||||
|
this.logger.log('debug', `${logPrefix}: found in upstream`, {
|
||||||
|
packageName,
|
||||||
|
versions: Object.keys(upstreamPackument.versions || {}).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return upstreamPackument;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withPackageVersionContext<T>(
|
||||||
|
packageName: string,
|
||||||
|
version: string,
|
||||||
|
actor: IRequestActor | undefined,
|
||||||
|
fn: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.storage.withContext(
|
||||||
|
{ protocol: 'npm', actor, metadata: { packageName, version } },
|
||||||
|
fn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleSearch(query: Record<string, string>): Promise<IResponse> {
|
private async handleSearch(query: Record<string, string>): Promise<IResponse> {
|
||||||
const text = query.text || '';
|
const text = query.text || '';
|
||||||
@@ -749,6 +731,22 @@ export class NpmRegistry extends BaseRegistry {
|
|||||||
this.logger.log('error', 'handleSearch failed', { error: (error as Error).message });
|
this.logger.log('error', 'handleSearch failed', { error: (error as Error).message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort results by relevance: exact match first, then prefix match, then substring match
|
||||||
|
if (text) {
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const aName = a.package.name.toLowerCase();
|
||||||
|
const bName = b.package.name.toLowerCase();
|
||||||
|
const aExact = aName === lowerText ? 0 : 1;
|
||||||
|
const bExact = bName === lowerText ? 0 : 1;
|
||||||
|
if (aExact !== bExact) return aExact - bExact;
|
||||||
|
const aPrefix = aName.startsWith(lowerText) ? 0 : 1;
|
||||||
|
const bPrefix = bName.startsWith(lowerText) ? 0 : 1;
|
||||||
|
if (aPrefix !== bPrefix) return aPrefix - bPrefix;
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedResults = results.slice(from, from + size);
|
const paginatedResults = results.slice(from, from + size);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -42,18 +42,7 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
this.ociTokens = ociTokens;
|
this.ociTokens = ociTokens;
|
||||||
this.upstreamProvider = upstreamProvider || null;
|
this.upstreamProvider = upstreamProvider || null;
|
||||||
|
|
||||||
// Initialize logger
|
this.logger = this.createProtocolLogger('oci-registry', 'oci');
|
||||||
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();
|
|
||||||
|
|
||||||
if (upstreamProvider) {
|
if (upstreamProvider) {
|
||||||
this.logger.log('info', 'OCI upstream provider configured');
|
this.logger.log('info', 'OCI upstream provider configured');
|
||||||
@@ -112,21 +101,15 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
// Remove base path from URL
|
// Remove base path from URL
|
||||||
const path = context.path.replace(this.basePath, '');
|
const path = context.path.replace(this.basePath, '');
|
||||||
|
|
||||||
// Extract token from Authorization header
|
const tokenString = this.extractBearerToken(context);
|
||||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
|
||||||
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
|
|
||||||
const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
|
const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
|
||||||
|
|
||||||
// Build actor from context and validated token
|
const actor: IRequestActor = this.buildRequestActor(context, 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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return this.storage.withContext({ protocol: 'oci', actor }, async () => {
|
||||||
// Route to appropriate handler
|
// Route to appropriate handler
|
||||||
if (path === '/' || path === '') {
|
// OCI spec: GET /v2/ is the version check endpoint
|
||||||
|
if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
|
||||||
return this.handleVersionCheck();
|
return this.handleVersionCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +164,7 @@ export class OciRegistry extends BaseRegistry {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: this.createError('NOT_FOUND', 'Endpoint not found'),
|
body: this.createError('NOT_FOUND', 'Endpoint not found'),
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async checkPermission(
|
protected async checkPermission(
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
// native scope
|
// native scope
|
||||||
|
import * as asyncHooks from 'node:async_hooks';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
export { path };
|
export { asyncHooks, path };
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as smartarchive from '@push.rocks/smartarchive';
|
import * as smartarchive from '@push.rocks/smartarchive';
|
||||||
|
|||||||
@@ -40,18 +40,7 @@ export class PypiRegistry extends BaseRegistry {
|
|||||||
this.registryUrl = registryUrl;
|
this.registryUrl = registryUrl;
|
||||||
this.upstreamProvider = upstreamProvider || null;
|
this.upstreamProvider = upstreamProvider || null;
|
||||||
|
|
||||||
// Initialize logger
|
this.logger = this.createProtocolLogger('pypi-registry', 'pypi');
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,14 +95,9 @@ export class PypiRegistry extends BaseRegistry {
|
|||||||
// Extract token (Basic Auth or Bearer)
|
// Extract token (Basic Auth or Bearer)
|
||||||
const token = await this.extractToken(context);
|
const token = await this.extractToken(context);
|
||||||
|
|
||||||
// Build actor from context and validated token
|
const actor: IRequestActor = this.buildRequestActor(context, 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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return this.storage.withContext({ protocol: 'pypi', actor }, async () => {
|
||||||
// Also handle /simple path prefix
|
// Also handle /simple path prefix
|
||||||
if (path.startsWith('/simple')) {
|
if (path.startsWith('/simple')) {
|
||||||
path = path.replace('/simple', '');
|
path = path.replace('/simple', '');
|
||||||
@@ -166,6 +150,7 @@ export class PypiRegistry extends BaseRegistry {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: { error: 'Not Found' },
|
body: { error: 'Not Found' },
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,14 +343,13 @@ export class PypiRegistry extends BaseRegistry {
|
|||||||
* Extract authentication token from request
|
* Extract authentication token from request
|
||||||
*/
|
*/
|
||||||
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
|
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
|
||||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
const authHeader = this.getAuthorizationHeader(context);
|
||||||
if (!authHeader) return null;
|
if (!authHeader) return null;
|
||||||
|
|
||||||
// Handle Basic Auth (username:password or __token__:token)
|
// Handle Basic Auth (username:password or __token__:token)
|
||||||
if (authHeader.startsWith('Basic ')) {
|
const basicCredentials = this.parseBasicAuthHeader(authHeader);
|
||||||
const base64 = authHeader.substring(6);
|
if (basicCredentials) {
|
||||||
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
|
const { username, password } = basicCredentials;
|
||||||
const [username, password] = decoded.split(':');
|
|
||||||
|
|
||||||
// PyPI token authentication: username = __token__
|
// PyPI token authentication: username = __token__
|
||||||
if (username === '__token__') {
|
if (username === '__token__') {
|
||||||
@@ -378,8 +362,8 @@ export class PypiRegistry extends BaseRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle Bearer token
|
// Handle Bearer token
|
||||||
if (authHeader.startsWith('Bearer ')) {
|
const token = this.extractBearerToken(authHeader);
|
||||||
const token = authHeader.substring(7);
|
if (token) {
|
||||||
return this.authManager.validateToken(token, 'pypi');
|
return this.authManager.validateToken(token, 'pypi');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,18 +41,7 @@ export class RubyGemsRegistry extends BaseRegistry {
|
|||||||
this.registryUrl = registryUrl;
|
this.registryUrl = registryUrl;
|
||||||
this.upstreamProvider = upstreamProvider || null;
|
this.upstreamProvider = upstreamProvider || null;
|
||||||
|
|
||||||
// Initialize logger
|
this.logger = this.createProtocolLogger('rubygems-registry', 'rubygems');
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,13 +103,7 @@ export class RubyGemsRegistry extends BaseRegistry {
|
|||||||
// Extract token (Authorization header)
|
// Extract token (Authorization header)
|
||||||
const token = await this.extractToken(context);
|
const token = await this.extractToken(context);
|
||||||
|
|
||||||
// Build actor from context and validated token
|
const actor: IRequestActor = this.buildRequestActor(context, 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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
||||||
method: context.method,
|
method: context.method,
|
||||||
@@ -128,6 +111,7 @@ export class RubyGemsRegistry extends BaseRegistry {
|
|||||||
hasAuth: !!token
|
hasAuth: !!token
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.storage.withContext({ protocol: 'rubygems', actor }, async () => {
|
||||||
// Compact Index endpoints
|
// Compact Index endpoints
|
||||||
if (path === '/versions' && context.method === 'GET') {
|
if (path === '/versions' && context.method === 'GET') {
|
||||||
return this.handleVersionsFile(context);
|
return this.handleVersionsFile(context);
|
||||||
@@ -174,6 +158,7 @@ export class RubyGemsRegistry extends BaseRegistry {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: { error: 'Not Found' },
|
body: { error: 'Not Found' },
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,7 +177,7 @@ export class RubyGemsRegistry extends BaseRegistry {
|
|||||||
* Extract authentication token from request
|
* Extract authentication token from request
|
||||||
*/
|
*/
|
||||||
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
|
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
|
||||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
const authHeader = this.getAuthorizationHeader(context);
|
||||||
if (!authHeader) return null;
|
if (!authHeader) return null;
|
||||||
|
|
||||||
// RubyGems typically uses plain API key in Authorization header
|
// RubyGems typically uses plain API key in Authorization header
|
||||||
|
|||||||
@@ -254,14 +254,12 @@ export function generateVersionsJson(
|
|||||||
uploadTime?: string;
|
uploadTime?: string;
|
||||||
}>
|
}>
|
||||||
): any {
|
): any {
|
||||||
return {
|
// RubyGems.org API returns a flat array at /api/v1/versions/{gem}.json
|
||||||
name: gemName,
|
return versions.map(v => ({
|
||||||
versions: versions.map(v => ({
|
|
||||||
number: v.version,
|
number: v.version,
|
||||||
platform: v.platform || 'ruby',
|
platform: v.platform || 'ruby',
|
||||||
built_at: v.uploadTime,
|
built_at: v.uploadTime,
|
||||||
})),
|
}));
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user