Compare commits

..

4 Commits

Author SHA1 Message Date
jkunz 2e2726a4de v2.9.1
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 14:18:12 +00:00
jkunz 0e35256062 fix(license): add missing MIT license file to repository 2026-04-16 14:18:12 +00:00
jkunz 10190a39fc v2.9.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 10:42:33 +00:00
jkunz 9643ef98b9 feat(registry): add declarative protocol routing and request-scoped storage hook context across registries 2026-04-16 10:42:33 +00:00
30 changed files with 2659 additions and 2695 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## 2026-04-16 - 2.9.1 - fix(license)
add missing MIT license file to repository
- Adds the project license file to align the repository contents with the package metadata license declaration.
## 2026-04-16 - 2.9.0 - feat(registry)
add declarative protocol routing and request-scoped storage hook context across registries
- Refactors protocol registration and request dispatch in SmartRegistry around shared registry descriptors.
- Wraps protocol request handling in storage context so hooks receive protocol, actor, package, and version metadata without cross-request leakage.
- Adds shared base registry helpers for header parsing, bearer/basic auth extraction, actor construction, and protocol logger creation.
- Improves NPM route parsing and publish helpers, including support for unencoded scoped package metadata and publish paths.
- Introduces centralized registry storage path helpers and expands test helpers and coverage for concurrent context isolation and real request hook metadata.
## 2026-03-27 - 2.8.2 - fix(maven,tests) ## 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 handle Maven Basic auth and accept deploy-plugin metadata/checksum uploads while stabilizing npm CLI test cleanup
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2025 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartregistry", "name": "@push.rocks/smartregistry",
"version": "2.8.2", "version": "2.9.1",
"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",
+26
View File
@@ -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==}
+311 -774
View File
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
import * as crypto from 'crypto';
import * as smartarchive from '@push.rocks/smartarchive';
/**
* Helper to calculate SHA-256 digest in OCI format
*/
export function calculateDigest(data: Buffer): string {
const hash = crypto.createHash('sha256').update(data).digest('hex');
return `sha256:${hash}`;
}
/**
* Helper to create a minimal valid OCI manifest
*/
export function createTestManifest(configDigest: string, layerDigest: string) {
return {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
config: {
mediaType: 'application/vnd.oci.image.config.v1+json',
size: 123,
digest: configDigest,
},
layers: [
{
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
size: 456,
digest: layerDigest,
},
],
};
}
/**
* Helper to create a minimal valid NPM packument
*/
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
return {
name: packageName,
versions: {
[version]: {
name: packageName,
version: version,
description: 'Test package',
main: 'index.js',
scripts: {},
dist: {
shasum: shasum,
integrity: integrity,
tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`,
},
},
},
'dist-tags': {
latest: version,
},
_attachments: {
[`${packageName}-${version}.tgz`]: {
content_type: 'application/octet-stream',
data: tarballData.toString('base64'),
length: tarballData.length,
},
},
};
}
/**
* Helper to create a minimal valid Maven POM file
*/
export function createTestPom(
groupId: string,
artifactId: string,
version: string,
packaging: string = 'jar'
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>${groupId}</groupId>
<artifactId>${artifactId}</artifactId>
<version>${version}</version>
<packaging>${packaging}</packaging>
<name>${artifactId}</name>
<description>Test Maven artifact</description>
</project>`;
}
/**
* Helper to create a test JAR file (minimal ZIP with manifest)
*/
export function createTestJar(): Buffer {
const manifestContent = `Manifest-Version: 1.0
Created-By: SmartRegistry Test
`;
return Buffer.from(manifestContent, 'utf-8');
}
/**
* Helper to calculate Maven checksums
*/
export function calculateMavenChecksums(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha1: crypto.createHash('sha1').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
sha512: crypto.createHash('sha512').update(data).digest('hex'),
};
}
/**
* Helper to create a Composer package ZIP using smartarchive
*/
export async function createComposerZip(
vendorPackage: string,
version: string,
options?: {
description?: string;
license?: string[];
authors?: Array<{ name: string; email?: string }>;
}
): Promise<Buffer> {
const zipTools = new smartarchive.ZipTools();
const composerJson = {
name: vendorPackage,
version: version,
type: 'library',
description: options?.description || 'Test Composer package',
license: options?.license || ['MIT'],
authors: options?.authors || [{ name: 'Test Author', email: 'test@example.com' }],
require: {
php: '>=7.4',
},
autoload: {
'psr-4': {
'Vendor\\TestPackage\\': 'src/',
},
},
};
const [vendor, pkg] = vendorPackage.split('/');
const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`;
const testPhpContent = `<?php
namespace ${namespace};
class TestClass
{
public function greet(): string
{
return "Hello from ${vendorPackage}!";
}
}
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: 'composer.json',
content: Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'),
},
{
archivePath: 'src/TestClass.php',
content: Buffer.from(testPhpContent, 'utf-8'),
},
{
archivePath: 'README.md',
content: Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'),
},
];
return Buffer.from(await zipTools.createZip(entries));
}
/**
* Helper to create a test Python wheel file (minimal ZIP structure) using smartarchive
*/
export async function createPythonWheel(
packageName: string,
version: string,
pyVersion: string = 'py3'
): Promise<Buffer> {
const zipTools = new smartarchive.ZipTools();
const normalizedName = packageName.replace(/-/g, '_');
const distInfoDir = `${normalizedName}-${version}.dist-info`;
const metadata = `Metadata-Version: 2.1
Name: ${packageName}
Version: ${version}
Summary: Test Python package
Home-page: https://example.com
Author: Test Author
Author-email: test@example.com
License: MIT
Platform: UNKNOWN
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.7
Description-Content-Type: text/markdown
# ${packageName}
Test package for SmartRegistry
`;
const wheelContent = `Wheel-Version: 1.0
Generator: test 1.0.0
Root-Is-Purelib: true
Tag: ${pyVersion}-none-any
`;
const moduleContent = `"""${packageName} module"""
__version__ = "${version}"
def hello():
return "Hello from ${packageName}!"
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: `${distInfoDir}/METADATA`,
content: Buffer.from(metadata, 'utf-8'),
},
{
archivePath: `${distInfoDir}/WHEEL`,
content: Buffer.from(wheelContent, 'utf-8'),
},
{
archivePath: `${distInfoDir}/RECORD`,
content: Buffer.from('', 'utf-8'),
},
{
archivePath: `${distInfoDir}/top_level.txt`,
content: Buffer.from(normalizedName, 'utf-8'),
},
{
archivePath: `${normalizedName}/__init__.py`,
content: Buffer.from(moduleContent, 'utf-8'),
},
];
return Buffer.from(await zipTools.createZip(entries));
}
/**
* Helper to create a test Python source distribution (sdist) using smartarchive
*/
export async function createPythonSdist(
packageName: string,
version: string
): Promise<Buffer> {
const tarTools = new smartarchive.TarTools();
const normalizedName = packageName.replace(/-/g, '_');
const dirPrefix = `${packageName}-${version}`;
const pkgInfo = `Metadata-Version: 2.1
Name: ${packageName}
Version: ${version}
Summary: Test Python package
Home-page: https://example.com
Author: Test Author
Author-email: test@example.com
License: MIT
`;
const setupPy = `from setuptools import setup, find_packages
setup(
name="${packageName}",
version="${version}",
packages=find_packages(),
python_requires=">=3.7",
)
`;
const moduleContent = `"""${packageName} module"""
__version__ = "${version}"
def hello():
return "Hello from ${packageName}!"
`;
const entries: smartarchive.IArchiveEntry[] = [
{
archivePath: `${dirPrefix}/PKG-INFO`,
content: Buffer.from(pkgInfo, 'utf-8'),
},
{
archivePath: `${dirPrefix}/setup.py`,
content: Buffer.from(setupPy, 'utf-8'),
},
{
archivePath: `${dirPrefix}/${normalizedName}/__init__.py`,
content: Buffer.from(moduleContent, 'utf-8'),
},
];
return Buffer.from(await tarTools.packFilesToTarGz(entries));
}
/**
* Helper to calculate PyPI file hashes
*/
export function calculatePypiHashes(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
blake2b: crypto.createHash('blake2b512').update(data).digest('hex'),
};
}
/**
* Helper to create a test RubyGem file (minimal tar.gz structure) using smartarchive
*/
export async function createRubyGem(
gemName: string,
version: string,
platform: string = 'ruby'
): Promise<Buffer> {
const tarTools = new smartarchive.TarTools();
const gzipTools = new smartarchive.GzipTools();
const metadataYaml = `--- !ruby/object:Gem::Specification
name: ${gemName}
version: !ruby/object:Gem::Version
version: ${version}
platform: ${platform}
authors:
- Test Author
autorequire:
bindir: bin
cert_chain: []
date: ${new Date().toISOString().split('T')[0]}
dependencies: []
description: Test RubyGem
email: test@example.com
executables: []
extensions: []
extra_rdoc_files: []
files:
- lib/${gemName}.rb
homepage: https://example.com
licenses:
- MIT
metadata: {}
post_install_message:
rdoc_options: []
require_paths:
- lib
required_ruby_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '2.7'
required_rubygems_version: !ruby/object:Gem::Requirement
requirements:
- - ">="
- !ruby/object:Gem::Version
version: '0'
requirements: []
rubygems_version: 3.0.0
signing_key:
specification_version: 4
summary: Test gem for SmartRegistry
test_files: []
`;
const metadataGz = Buffer.from(await gzipTools.compress(Buffer.from(metadataYaml, 'utf-8')));
const libContent = `# ${gemName}
module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')}
VERSION = "${version}"
def self.hello
"Hello from #{gemName}!"
end
end
`;
const dataEntries: smartarchive.IArchiveEntry[] = [
{
archivePath: `lib/${gemName}.rb`,
content: Buffer.from(libContent, 'utf-8'),
},
];
const dataTarGz = Buffer.from(await tarTools.packFilesToTarGz(dataEntries));
const gemEntries: smartarchive.IArchiveEntry[] = [
{
archivePath: 'metadata.gz',
content: metadataGz,
},
{
archivePath: 'data.tar.gz',
content: dataTarGz,
},
];
return Buffer.from(await tarTools.packFiles(gemEntries));
}
/**
* Helper to calculate RubyGems checksums
*/
export function calculateRubyGemsChecksums(data: Buffer) {
return {
md5: crypto.createHash('md5').update(data).digest('hex'),
sha256: crypto.createHash('sha256').update(data).digest('hex'),
};
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Generate a unique test run ID for avoiding conflicts between test runs.
*/
export function generateTestRunId(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 6);
return `${timestamp}${random}`;
}
+125
View File
@@ -0,0 +1,125 @@
import type { IAuthToken, TRegistryProtocol } from '../../ts/core/interfaces.core.js';
import type { IAuthProvider } from '../../ts/core/interfaces.auth.js';
import type {
IUpstreamProvider,
IUpstreamRegistryConfig,
IUpstreamResolutionContext,
IProtocolUpstreamConfig,
} from '../../ts/upstream/interfaces.upstream.js';
type TTestUpstreamRegistryConfig = Omit<Partial<IUpstreamRegistryConfig>, 'id' | 'url' | 'priority' | 'enabled'> &
Pick<IUpstreamRegistryConfig, 'id' | 'url' | 'priority' | 'enabled'>;
type TTestProtocolUpstreamConfig = Omit<IProtocolUpstreamConfig, 'upstreams'> & {
upstreams: TTestUpstreamRegistryConfig[];
};
function normalizeUpstreamRegistryConfig(
upstream: TTestUpstreamRegistryConfig
): IUpstreamRegistryConfig {
return {
...upstream,
name: upstream.name ?? upstream.id,
auth: upstream.auth ?? { type: 'none' },
};
}
function normalizeProtocolUpstreamConfig(
config: TTestProtocolUpstreamConfig | undefined
): IProtocolUpstreamConfig | null {
if (!config) {
return null;
}
return {
...config,
upstreams: config.upstreams.map(normalizeUpstreamRegistryConfig),
};
}
/**
* Create a mock upstream provider that tracks all calls for testing
*/
export function createTrackingUpstreamProvider(
baseConfig?: Partial<Record<TRegistryProtocol, TTestProtocolUpstreamConfig>>
): {
provider: IUpstreamProvider;
calls: IUpstreamResolutionContext[];
} {
const calls: IUpstreamResolutionContext[] = [];
const provider: IUpstreamProvider = {
async resolveUpstreamConfig(context: IUpstreamResolutionContext) {
calls.push({ ...context });
return normalizeProtocolUpstreamConfig(baseConfig?.[context.protocol]);
},
};
return { provider, calls };
}
/**
* Create a mock auth provider for testing pluggable authentication.
* Allows customizing behavior for different test scenarios.
*/
export function createMockAuthProvider(overrides?: Partial<IAuthProvider>): IAuthProvider {
const tokens = new Map<string, IAuthToken>();
return {
init: async () => {},
authenticate: async (credentials) => {
return credentials.username;
},
validateToken: async (token, protocol) => {
const stored = tokens.get(token);
if (stored && (!protocol || stored.type === protocol)) {
return stored;
}
if (token === 'valid-mock-token') {
return {
type: 'npm' as TRegistryProtocol,
userId: 'mock-user',
scopes: ['npm:*:*:*'],
};
}
return null;
},
createToken: async (userId, protocol, options) => {
const tokenId = `mock-${protocol}-${Date.now()}`;
const authToken: IAuthToken = {
type: protocol,
userId,
scopes: options?.scopes || [`${protocol}:*:*:*`],
readonly: options?.readonly,
expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn * 1000) : undefined,
};
tokens.set(tokenId, authToken);
return tokenId;
},
revokeToken: async (token) => {
tokens.delete(token);
},
authorize: async (token, resource, action) => {
if (!token) return false;
if (token.readonly && ['write', 'push', 'delete'].includes(action)) {
return false;
}
return true;
},
listUserTokens: async (userId) => {
const result: Array<{ key: string; readonly: boolean; created: string; protocol?: TRegistryProtocol }> = [];
for (const [key, token] of tokens.entries()) {
if (token.userId === userId) {
result.push({
key: `hash-${key.substring(0, 8)}`,
readonly: token.readonly || false,
created: new Date().toISOString(),
protocol: token.type,
});
}
}
return result;
},
...overrides,
};
}
+34 -869
View File
@@ -1,30 +1,34 @@
import * as qenv from '@push.rocks/qenv'; import * as 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 };
}
+122
View File
@@ -0,0 +1,122 @@
import * as qenv from '@push.rocks/qenv';
import type { IRegistryConfig } from '../../ts/core/interfaces.core.js';
import type { IStorageHooks } from '../../ts/core/interfaces.storage.js';
import { StaticUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
import type { IUpstreamProvider } from '../../ts/upstream/interfaces.upstream.js';
const testQenv = new qenv.Qenv('./', './.nogit');
async function getTestStorageConfig(): Promise<IRegistryConfig['storage']> {
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
return {
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
region: 'us-east-1',
bucketName: 'test-registry',
};
}
function getTestAuthConfig(): IRegistryConfig['auth'] {
return {
jwtSecret: 'test-secret-key',
tokenStore: 'memory',
npmTokens: {
enabled: true,
},
ociTokens: {
enabled: true,
realm: 'https://auth.example.com/token',
service: 'test-registry',
},
pypiTokens: {
enabled: true,
},
rubygemsTokens: {
enabled: true,
},
};
}
export function createDefaultTestUpstreamProvider(): IUpstreamProvider {
return new StaticUpstreamProvider({
npm: {
enabled: true,
upstreams: [{
id: 'npmjs',
name: 'npmjs',
url: 'https://registry.npmjs.org',
priority: 1,
enabled: true,
auth: { type: 'none' },
}],
},
oci: {
enabled: true,
upstreams: [{
id: 'dockerhub',
name: 'dockerhub',
url: 'https://registry-1.docker.io',
priority: 1,
enabled: true,
auth: { type: 'none' },
}],
},
});
}
export async function buildTestRegistryConfig(options?: {
registryUrl?: string;
storageHooks?: IStorageHooks;
upstreamProvider?: IUpstreamProvider;
}): Promise<IRegistryConfig> {
const config: IRegistryConfig = {
storage: await getTestStorageConfig(),
auth: getTestAuthConfig(),
...(options?.storageHooks ? { storageHooks: options.storageHooks } : {}),
...(options?.upstreamProvider ? { upstreamProvider: options.upstreamProvider } : {}),
oci: {
enabled: true,
basePath: '/oci',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/oci` } : {}),
},
npm: {
enabled: true,
basePath: '/npm',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/npm` } : {}),
},
maven: {
enabled: true,
basePath: '/maven',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/maven` } : {}),
},
composer: {
enabled: true,
basePath: '/composer',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/composer` } : {}),
},
cargo: {
enabled: true,
basePath: '/cargo',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/cargo` } : {}),
},
pypi: {
enabled: true,
basePath: '/pypi',
...(options?.registryUrl ? { registryUrl: options.registryUrl } : {}),
},
rubygems: {
enabled: true,
basePath: '/rubygems',
...(options?.registryUrl ? { registryUrl: `${options.registryUrl}/rubygems` } : {}),
},
};
return config;
}
+72
View File
@@ -0,0 +1,72 @@
import * as qenv from '@push.rocks/qenv';
import * as smartbucket from '@push.rocks/smartbucket';
import { generateTestRunId } from './ids.js';
const testQenv = new qenv.Qenv('./', './.nogit');
/**
* Create a SmartBucket storage backend for upstream cache testing.
*/
export async function createTestStorageBackend(): Promise<{
storage: {
getObject: (key: string) => Promise<Buffer | null>;
putObject: (key: string, data: Buffer) => Promise<void>;
deleteObject: (key: string) => Promise<void>;
listObjects: (prefix: string) => Promise<string[]>;
};
bucket: smartbucket.Bucket;
cleanup: () => Promise<void>;
}> {
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
const s3 = new smartbucket.SmartBucket({
accessKey: s3AccessKey || 'minioadmin',
accessSecret: s3SecretKey || 'minioadmin',
endpoint: s3Endpoint || 'localhost',
port: parseInt(s3Port || '9000', 10),
useSsl: false,
});
const testRunId = generateTestRunId();
const bucketName = 'test-cache-' + testRunId.substring(0, 8);
const bucket = await s3.createBucket(bucketName);
const storage = {
getObject: async (key: string): Promise<Buffer | null> => {
try {
return await bucket.fastGet({ path: key });
} catch {
return null;
}
},
putObject: async (key: string, data: Buffer): Promise<void> => {
await bucket.fastPut({ path: key, contents: data, overwrite: true });
},
deleteObject: async (key: string): Promise<void> => {
await bucket.fastRemove({ path: key });
},
listObjects: async (prefix: string): Promise<string[]> => {
const paths: string[] = [];
for await (const path of bucket.listAllObjects(prefix)) {
paths.push(path);
}
return paths;
},
};
const cleanup = async () => {
try {
for await (const path of bucket.listAllObjects()) {
await bucket.fastRemove({ path });
}
await s3.removeBucket(bucketName);
} catch {
// Ignore cleanup errors
}
};
return { storage, bucket, cleanup };
}
+82
View File
@@ -0,0 +1,82 @@
import type { IStorageHooks, IStorageHookContext } from '../../ts/core/interfaces.storage.js';
/**
* Create test storage hooks that track all calls.
* Useful for verifying hook invocation order and parameters.
*/
export function createTrackingHooks(options?: {
beforePutAllowed?: boolean;
beforeDeleteAllowed?: boolean;
throwOnAfterPut?: boolean;
throwOnAfterGet?: boolean;
}): {
hooks: IStorageHooks;
calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }>;
} {
const calls: Array<{ method: string; context: IStorageHookContext; timestamp: number }> = [];
return {
calls,
hooks: {
beforePut: async (ctx) => {
calls.push({ method: 'beforePut', context: ctx, timestamp: Date.now() });
return {
allowed: options?.beforePutAllowed !== false,
reason: options?.beforePutAllowed === false ? 'Blocked by test' : undefined,
};
},
afterPut: async (ctx) => {
calls.push({ method: 'afterPut', context: ctx, timestamp: Date.now() });
if (options?.throwOnAfterPut) {
throw new Error('Test error in afterPut');
}
},
beforeDelete: async (ctx) => {
calls.push({ method: 'beforeDelete', context: ctx, timestamp: Date.now() });
return {
allowed: options?.beforeDeleteAllowed !== false,
reason: options?.beforeDeleteAllowed === false ? 'Blocked by test' : undefined,
};
},
afterDelete: async (ctx) => {
calls.push({ method: 'afterDelete', context: ctx, timestamp: Date.now() });
},
afterGet: async (ctx) => {
calls.push({ method: 'afterGet', context: ctx, timestamp: Date.now() });
if (options?.throwOnAfterGet) {
throw new Error('Test error in afterGet');
}
},
},
};
}
/**
* Create a blocking storage hooks implementation for quota testing.
*/
export function createQuotaHooks(maxSizeBytes: number): {
hooks: IStorageHooks;
currentUsage: { bytes: number };
} {
const currentUsage = { bytes: 0 };
return {
currentUsage,
hooks: {
beforePut: async (ctx) => {
const size = ctx.metadata?.size || 0;
if (currentUsage.bytes + size > maxSizeBytes) {
return { allowed: false, reason: `Quota exceeded: ${currentUsage.bytes + size} > ${maxSizeBytes}` };
}
return { allowed: true };
},
afterPut: async (ctx) => {
currentUsage.bytes += ctx.metadata?.size || 0;
},
afterDelete: async (ctx) => {
currentUsage.bytes -= ctx.metadata?.size || 0;
if (currentUsage.bytes < 0) currentUsage.bytes = 0;
},
},
};
}
+27
View File
@@ -0,0 +1,27 @@
import type { SmartRegistry } from '../../ts/classes.smartregistry.js';
/**
* Helper to create test authentication tokens.
*/
export async function createTestTokens(registry: SmartRegistry) {
const authManager = registry.getAuthManager();
const userId = await authManager.authenticate({
username: 'testuser',
password: 'testpass',
});
if (!userId) {
throw new Error('Failed to authenticate test user');
}
const npmToken = await authManager.createNpmToken(userId, false);
const ociToken = await authManager.createOciToken(userId, ['oci:repository:*:*'], 3600);
const mavenToken = await authManager.createMavenToken(userId, false);
const composerToken = await authManager.createComposerToken(userId, false);
const cargoToken = await authManager.createCargoToken(userId, false);
const pypiToken = await authManager.createPypiToken(userId, false);
const rubygemsToken = await authManager.createRubyGemsToken(userId, false);
return { npmToken, ociToken, mavenToken, composerToken, cargoToken, pypiToken, rubygemsToken, userId };
}
+45 -1
View File
@@ -1,7 +1,7 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { 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',
+142 -1
View File
@@ -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
// ============================================================================ // ============================================================================
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartregistry', name: '@push.rocks/smartregistry',
version: '2.8.2', version: '2.9.1',
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries' description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
} }
+5 -20
View File
@@ -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
View File
@@ -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();
}
} }
} }
} }
+11 -14
View File
@@ -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(
+118 -1
View File
@@ -1,14 +1,131 @@
import type { IRequestContext, IResponse, IAuthToken } from './interfaces.core.js'; import * as plugins from '../plugins.js';
import type { IRequestContext, IResponse, IAuthToken, IRequestActor } from './interfaces.core.js';
/** /**
* Abstract base class for all registry protocol implementations * 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
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
function digestToHash(digest: string): string {
return digest.split(':')[1];
}
export function getOciBlobPath(digest: string): string {
return `oci/blobs/sha256/${digestToHash(digest)}`;
}
export function getOciManifestPath(repository: string, digest: string): string {
return `oci/manifests/${repository}/${digestToHash(digest)}`;
}
export function getNpmPackumentPath(packageName: string): string {
return `npm/packages/${packageName}/index.json`;
}
export function getNpmTarballPath(packageName: string, version: string): string {
const safeName = packageName.replace('@', '').replace('/', '-');
return `npm/packages/${packageName}/${safeName}-${version}.tgz`;
}
export function getMavenArtifactPath(
groupId: string,
artifactId: string,
version: string,
filename: string
): string {
const groupPath = groupId.replace(/\./g, '/');
return `maven/artifacts/${groupPath}/${artifactId}/${version}/${filename}`;
}
export function getMavenMetadataPath(groupId: string, artifactId: string): string {
const groupPath = groupId.replace(/\./g, '/');
return `maven/metadata/${groupPath}/${artifactId}/maven-metadata.xml`;
}
export function getCargoConfigPath(): string {
return 'cargo/config.json';
}
export function getCargoIndexPath(crateName: string): string {
const lower = crateName.toLowerCase();
const len = lower.length;
if (len === 1) {
return `cargo/index/1/${lower}`;
}
if (len === 2) {
return `cargo/index/2/${lower}`;
}
if (len === 3) {
return `cargo/index/3/${lower.charAt(0)}/${lower}`;
}
const prefix1 = lower.substring(0, 2);
const prefix2 = lower.substring(2, 4);
return `cargo/index/${prefix1}/${prefix2}/${lower}`;
}
export function getCargoCratePath(crateName: string, version: string): string {
return `cargo/crates/${crateName}/${crateName}-${version}.crate`;
}
export function getComposerMetadataPath(vendorPackage: string): string {
return `composer/packages/${vendorPackage}/metadata.json`;
}
export function getComposerZipPath(vendorPackage: string, reference: string): string {
return `composer/packages/${vendorPackage}/${reference}.zip`;
}
export function getPypiMetadataPath(packageName: string): string {
return `pypi/metadata/${packageName}/metadata.json`;
}
export function getPypiSimpleIndexPath(packageName: string): string {
return `pypi/simple/${packageName}/index.html`;
}
export function getPypiSimpleRootIndexPath(): string {
return 'pypi/simple/index.html';
}
export function getPypiPackageFilePath(packageName: string, filename: string): string {
return `pypi/packages/${packageName}/${filename}`;
}
export function getRubyGemsVersionsPath(): string {
return 'rubygems/versions';
}
export function getRubyGemsInfoPath(gemName: string): string {
return `rubygems/info/${gemName}`;
}
export function getRubyGemsNamesPath(): string {
return 'rubygems/names';
}
export function getRubyGemsGemPath(gemName: string, version: string, platform?: string): string {
const filename = platform ? `${gemName}-${version}-${platform}.gem` : `${gemName}-${version}.gem`;
return `rubygems/gems/${filename}`;
}
export function getRubyGemsMetadataPath(gemName: string): string {
return `rubygems/metadata/${gemName}/metadata.json`;
}
+9 -17
View File
@@ -105,32 +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) {
if (/^Basic\s+/i.test(authHeader)) { const basicCredentials = this.parseBasicAuthHeader(authHeader);
if (basicCredentials) {
// Maven sends Basic Auth: base64(username:password) — extract the password as token // Maven sends Basic Auth: base64(username:password) — extract the password as token
const base64 = authHeader.replace(/^Basic\s+/i, ''); token = await this.authManager.validateToken(basicCredentials.password, 'maven');
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const colonIndex = decoded.indexOf(':');
const password = colonIndex >= 0 ? decoded.substring(colonIndex + 1) : decoded;
token = await this.authManager.validateToken(password, 'maven');
} else { } else {
const tokenString = authHeader.replace(/^Bearer\s+/i, ''); const tokenString = this.extractBearerToken(authHeader);
token = await this.authManager.validateToken(tokenString, 'maven'); 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);
@@ -155,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(
+157 -175
View File
@@ -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 || '';
+79
View File
@@ -0,0 +1,79 @@
import * as crypto from 'node:crypto';
import type { IPackument, IPublishRequest, INpmVersion } from './interfaces.npm.js';
function getTarballFileName(packageName: string, version: string): string {
const safeName = packageName.replace('@', '').replace('/', '-');
return `${safeName}-${version}.tgz`;
}
export function createNewPackument(
packageName: string,
body: IPublishRequest,
timestamp: string
): IPackument {
return {
_id: packageName,
name: packageName,
description: body.description,
'dist-tags': body['dist-tags'] || { latest: Object.keys(body.versions)[0] },
versions: {},
time: {
created: timestamp,
modified: timestamp,
},
maintainers: body.maintainers || [],
readme: body.readme,
};
}
export function getAttachmentForVersion(
body: IPublishRequest,
version: string
): IPublishRequest['_attachments'][string] | null {
const attachmentKey = Object.keys(body._attachments).find((key) => key.includes(version));
return attachmentKey ? body._attachments[attachmentKey] : null;
}
export function preparePublishedVersion(options: {
packageName: string;
version: string;
versionData: INpmVersion;
attachment: IPublishRequest['_attachments'][string];
registryUrl: string;
userId?: string;
}): { tarballBuffer: Buffer; versionData: INpmVersion } {
const tarballBuffer = Buffer.from(options.attachment.data, 'base64');
const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex');
const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`;
const tarballFileName = getTarballFileName(options.packageName, options.version);
return {
tarballBuffer,
versionData: {
...options.versionData,
dist: {
...options.versionData.dist,
tarball: `${options.registryUrl}/${options.packageName}/-/${tarballFileName}`,
shasum,
integrity,
fileCount: 0,
unpackedSize: tarballBuffer.length,
},
_id: `${options.packageName}@${options.version}`,
...(options.userId ? { _npmUser: { name: options.userId, email: '' } } : {}),
},
};
}
export function recordPublishedVersion(
packument: IPackument,
version: string,
versionData: INpmVersion,
timestamp: string
): void {
packument.versions[version] = versionData;
if (packument.time) {
packument.time[version] = timestamp;
packument.time.modified = timestamp;
}
}
+110
View File
@@ -0,0 +1,110 @@
export type TNpmRequestRoute =
| { type: 'root' }
| { type: 'search' }
| { type: 'userAuth'; username: string }
| { type: 'tokens'; path: string }
| { type: 'distTags'; packageName: string; tag?: string }
| { type: 'tarball'; packageName: string; filename: string }
| { type: 'unpublishVersion'; packageName: string; version: string }
| { type: 'unpublishPackage'; packageName: string; rev: string }
| { type: 'packageVersion'; packageName: string; version: string }
| { type: 'package'; packageName: string };
function decodePackageName(rawPackageName: string): string {
return decodeURIComponent(rawPackageName);
}
export function parseNpmRequestRoute(path: string, method: string): TNpmRequestRoute | null {
if (path === '/' || path === '') {
return { type: 'root' };
}
if (path.startsWith('/-/v1/search')) {
return { type: 'search' };
}
const userMatch = path.match(/^\/-\/user\/org\.couchdb\.user:(.+)$/);
if (userMatch) {
return {
type: 'userAuth',
username: userMatch[1],
};
}
if (path.startsWith('/-/npm/v1/tokens')) {
return {
type: 'tokens',
path,
};
}
const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/);
if (distTagsMatch) {
const [, rawPackageName, tag] = distTagsMatch;
return {
type: 'distTags',
packageName: decodePackageName(rawPackageName),
tag,
};
}
const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/);
if (tarballMatch) {
const [, rawPackageName, filename] = tarballMatch;
return {
type: 'tarball',
packageName: decodePackageName(rawPackageName),
filename,
};
}
if (method === 'DELETE') {
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
if (unpublishVersionMatch) {
const [, rawPackageName, version] = unpublishVersionMatch;
return {
type: 'unpublishVersion',
packageName: decodePackageName(rawPackageName),
version,
};
}
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
if (unpublishPackageMatch) {
const [, rawPackageName, rev] = unpublishPackageMatch;
return {
type: 'unpublishPackage',
packageName: decodePackageName(rawPackageName),
rev,
};
}
}
const unencodedScopedPackageMatch = path.match(/^\/@[^\/]+\/[^\/]+$/);
if (unencodedScopedPackageMatch) {
return {
type: 'package',
packageName: decodePackageName(path.substring(1)),
};
}
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
if (versionMatch) {
const [, rawPackageName, version] = versionMatch;
return {
type: 'packageVersion',
packageName: decodePackageName(rawPackageName),
version,
};
}
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
if (packageMatch) {
return {
type: 'package',
packageName: decodePackageName(packageMatch[1]),
};
}
return null;
}
+5 -22
View File
@@ -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,19 +101,12 @@ 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
// OCI spec: GET /v2/ is the version check endpoint // OCI spec: GET /v2/ is the version check endpoint
if (path === '/' || path === '' || path === '/v2/' || path === '/v2') { if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
@@ -182,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
View File
@@ -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';
+10 -26
View File
@@ -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');
} }
+5 -20
View File
@@ -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