Skip to content

feat(pnpr): add registry/resolver feature toggles#12563

Merged
zkochan merged 9 commits into
mainfrom
pnpr-feature-toggles
Jun 22, 2026
Merged

feat(pnpr): add registry/resolver feature toggles#12563
zkochan merged 9 commits into
mainfrom
pnpr-feature-toggles

Conversation

@zkochan

@zkochan zkochan commented Jun 21, 2026

Copy link
Copy Markdown
Member

Summary

Adds two independently toggleable surfaces to the pnpr server, configured via per-surface blocks (both enabled by default):

registry:
  enabled: true   # packuments, tarballs, publish, search, users
resolver:
  enabled: true   # /-/pnpr, /v1/resolve, /v1/verify-lockfile
  • Disable the registry to run a stateless resolver-only tier in front of an existing registry (the install-accelerator use case).
  • Disable the resolver to run a registry-only server with no server-side resolution.
  • /-/ping is always served. At least one surface must stay enabled (validated at config load and again after CLI overrides). CLI flags --disable-registry / --disable-resolver override the config.

OSV stays a top-level setting rather than nesting under either surface, because it is a cross-cutting policy. The registry serving path does not yet apply OSV screening; that gap is tracked in #12561.

Squash Commit Body

pnpr now exposes its registry and resolver surfaces as independent,
config-toggleable features (per-surface `registry:` / `resolver:` blocks,
both enabled by default; CLI `--disable-registry` / `--disable-resolver`).
This supports running a stateless resolver-only tier in front of an
existing registry, or a registry with no server-side resolution. At least
one surface must stay enabled, validated at config load and after CLI
overrides.

The resolver paths overlap the registry's catch-all param routes, so when
the resolver is disabled they are registered to an explicit 404 stub —
otherwise a capability probe (GET /-/pnpr) would fall through to the
registry and be proxied upstream, surfacing a 502 where clients expect the
"no resolver" 404.

OSV stays a top-level (cross-cutting) setting rather than nesting under a
surface. Registry-side OSV screening is not yet implemented; tracked in
pnpm/pnpm#12561.

Checklist

  • The change is implemented in both the TypeScript CLI and the Rust pacquet/ port, or the description notes what still needs porting. — N/A: this is a change to the standalone pnpr server (its own subproject), which has no TypeScript-CLI or pacquet counterpart.
  • Added a changeset if this PR changes any published package. — N/A: pnpr is the Rust server binary; no published JS package is affected, so the JS changeset flow does not apply.
  • Added or updated tests. — resolver_only / registry_only server tests, plus config unit tests for feature parsing and the both-disabled validation error.
  • Updated the documentation if needed. — documented the registry: / resolver: blocks in the bundled config.yaml.

Written by an agent (Claude Code, claude-opus-4-8).

Summary by CodeRabbit

  • New Features
    • Added YAML/runtime feature toggles to independently enable/disable the registry and resolver surfaces; /-/ping is always served.
    • Added CLI flags --disable_registry and --disable_resolver to override toggles at startup.
    • Added validation to prevent configurations where both surfaces are disabled.
  • Tests
    • Added unit tests for toggle parsing (including YAML omission/null behavior and unknown/typo keys) and for “strict” registry-side handling when registry is disabled.
    • Added integration tests to verify correct route mounting and 404 behavior when each surface is disabled.

zkochan added 2 commits June 21, 2026 22:04
pnpr now exposes two independently toggleable surfaces via per-surface
config blocks, both enabled by default:

  registry:
    enabled: true   # packuments, tarballs, publish, search, users
  resolver:
    enabled: true   # /-/pnpr, /v1/resolve, /v1/verify-lockfile

Disabling the registry runs a stateless resolver tier in front of an
existing registry; disabling the resolver runs a plain registry with no
server-side resolution. At least one surface must stay enabled
(validated at config load and again after CLI overrides). The CLI flags
--disable-registry and --disable-resolver override the config.

The resolver paths overlap the registry's catch-all param routes, so
when the resolver is disabled they are registered to an explicit 404
stub. Without it a capability probe (GET /-/pnpr) would fall through to
the registry and be proxied upstream, surfacing a 502 where clients
expect the "no resolver" 404.

OSV stays a top-level setting rather than nesting under either surface,
because it is a cross-cutting policy. Registry-side OSV screening is not
yet implemented and is tracked in #12561.
These types are part of `Config`'s public surface (its `registry` and
`resolver` fields), so they must be re-exported from the crate root like
the other config types. Without it, the public `Config` field docs link
to private items and `cargo doc -D warnings` fails.
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds registry and resolver feature flags to the pnpr server config. New RegistryFeature and ResolverFeature types with enabled: bool fields are introduced, wired through YAML parsing with validation, and exposed via CLI --disable_registry and --disable_resolver flags. Server routing conditionally mounts each surface to real handlers or returns 404 stubs based on active flags; /-/ping is always served.

Changes

Registry/Resolver Feature Flags

Layer / File(s) Summary
Feature flag types and config schema
pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/config.yaml
Defines RegistryFeature, ResolverFeature, and FeatureOverrides structs with enabled: bool defaulting to true and CLI disable flags. Introduces FeatureFile for YAML deserialization with deny_unknown_fields. Extends Config and ConfigFile with optional registry and resolver feature blocks that default to enabled when absent or null. Adds commented documentation in config.yaml describing the two surfaces and their defaults.
Config parsing, validation, and tests
pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/src/config/tests.rs
Config::proxy and Config::static_serve populate new feature fields with defaults. Config::resolve_with_overrides accepts FeatureOverrides to apply CLI disable flags during parsing. from_yaml_str_with_overrides computes effective enablement by combining YAML values with overrides, skips uplink resolution when registry is disabled, and ensure_a_feature_is_enabled() validator rejects all-disabled configs. Seven unit tests verify default enabling, null/empty acceptance, explicit parsing, uplink skipping, unknown key rejection, and all-disabled error.
Public API re-exports
pnpr/crates/pnpr/src/lib.rs
RegistryFeature and ResolverFeature are added to the pub use config::{...} list.
CLI flags and startup integration
pnpr/crates/pnpr/src/main.rs
Adds --disable_registry and --disable_resolver CLI arguments; main startup calls Config::resolve_with_overrides with FeatureOverrides to incorporate flags during config resolution. Startup comment documents that overrides are applied during parse and resolution enforces at least one surface enabled.
Server startup helpers and conditional route mounting
pnpr/crates/pnpr/src/server.rs
Introduces load_active_osv_index (loads OSV only when resolver.enabled is true) and load_startup_auth (runs journal recovery and loads persisted auth only when registry.enabled is true). router_with_auth_and_osv reads registry_enabled/resolver_enabled flags and conditionally mounts resolver endpoints to real handlers or a resolver_disabled 404 stub; npm-registry routes mount only when registry_enabled is true; /-/ping always mounts. serve and serve_listener integrate these helpers. Imports axum::routing::any for method-agnostic route handling.
Integration tests for conditional routing
pnpr/crates/pnpr/tests/server.rs
Two tests verify that disabling registry keeps resolver routes functional while registry routes return 404, and vice versa for resolver disabling; extra coverage ensures non-matching HTTP method/path combinations do not fall through to registry catch-all behavior.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI arguments
  participant Config as Config::resolve_with_overrides
  participant Startup as serve/serve_listener
  participant Auth as load_startup_auth
  participant OSV as load_active_osv_index
  participant Router as router_with_auth_and_osv
  participant ResolverPaths as resolver routes
  participant RegistryPaths as registry routes
  
  CLI->>Config: parse YAML + apply --disable flags
  Config->>Config: compute effective enablement
  Config->>Config: validate at least one enabled
  Config->>Startup: return validated Config
  Startup->>Auth: if registry.enabled: recover journal & load auth
  Auth->>Startup: auth backend (or in-memory)
  Startup->>OSV: if resolver.enabled: load OSV index
  OSV->>Startup: OSV (or empty)
  Startup->>Router: pass enabled flags + backends
  Router->>ResolverPaths: enabled? mount handlers : mount 404 stub
  Router->>RegistryPaths: enabled? mount routes : skip
  Router->>Startup: return wired router
  Startup->>ResolverPaths: incoming request
  ResolverPaths-->>Startup: response (handler or 404)
  Startup->>RegistryPaths: incoming request
  RegistryPaths-->>Startup: response (handler or 404)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pnpr-feature-toggles

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

pnpr: add toggleable registry/resolver surfaces (config + CLI)
✨ Enhancement 🧪 Tests 📝 Documentation 🐞 Bug fix 🕐 20-40 Minutes

Grey Divider

Description

• Add registry/resolver feature blocks with defaults and “at least one enabled” validation.
• Add CLI --disable-registry / --disable-resolver overrides for per-run surface control.
• Conditionally mount registry/resolver routes (with resolver 404 stubs) and add coverage tests.
Diagram

graph TD
  A["Config sources"] --> B["Config (features)"] --> C["Feature validation"] --> D["Router builder"]
  D --> E["Registry routes"] --> G["Upstream registry"]
  D --> F["Resolver routes"] --> H[("OSV DB")] --> G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Middleware-gated handlers (keep routes always mounted)
  • ➕ Simplifies router construction; route table stays stable
  • ➕ Uniform gating across endpoints
  • ➖ Doesn’t address / -/pnpr falling into registry catch-alls without explicit stubs
  • ➖ Resolver-only mode would still “expose” registry routes (even if 404/403) unless carefully handled
2. Sub-routers + `merge()` per surface
  • ➕ Clearer separation of registry vs resolver wiring
  • ➕ Easier to grow future per-surface sub-features
  • ➖ Still needs explicit resolver-path 404 stubs when disabled due to overlap with param routes
  • ➖ Primarily a readability refactor; little functional improvement

Recommendation: The current approach is appropriate: conditional mounting truly removes a surface, and explicit 404 stubs on resolver paths prevent capability probes (GET /-/pnpr) from being captured by registry catch-all routes and proxied upstream (avoiding confusing 502s). A future refactor could build dedicated sub-routers for readability, but should preserve the explicit stub behavior.

Files changed (7) +322 / -29

Enhancement (3) +184 / -28
config.rsAdd feature structs, YAML parsing, and surface-enabled validation +97/-2

Add feature structs, YAML parsing, and surface-enabled validation

• Introduces 'RegistryFeature' and 'ResolverFeature' fields on 'Config', defaulting to enabled. Extends YAML parsing with 'registry:' / 'resolver:' blocks and adds 'ensure_a_feature_is_enabled()' called at config load.

pnpr/crates/pnpr/src/config.rs

main.rsAdd CLI flags to disable registry/resolver surfaces +21/-0

Add CLI flags to disable registry/resolver surfaces

• Adds '--disable-registry' and '--disable-resolver' flags that override config values only when present. Re-runs the “at least one enabled” check after applying CLI overrides.

pnpr/crates/pnpr/src/main.rs

server.rsConditionally mount route surfaces and stub resolver paths when disabled +66/-26

Conditionally mount route surfaces and stub resolver paths when disabled

• Always mounts '/-/ping', mounts resolver endpoints only when enabled (otherwise registers those exact paths to a 404 stub), and mounts registry catch-all routes only when enabled. The 404 stub prevents resolver probes from falling through to registry param routes and being proxied upstream.

pnpr/crates/pnpr/src/server.rs

Bug fix (1) +2 / -1
lib.rsRe-export 'RegistryFeature' and 'ResolverFeature' +2/-1

Re-export 'RegistryFeature' and 'ResolverFeature'

• Exports the feature types from the crate root because they are part of 'Config'’s public API, preventing doc-link privacy warnings (and 'cargo doc -D warnings' failures).

pnpr/crates/pnpr/src/lib.rs

Tests (2) +129 / -0
tests.rsAdd unit tests for feature defaults, parsing, and both-disabled error +33/-0

Add unit tests for feature defaults, parsing, and both-disabled error

• Verifies features default to enabled when absent, parses explicit per-surface toggles, and rejects configs that disable both surfaces.

pnpr/crates/pnpr/src/config/tests.rs

server.rsAdd integration tests for resolver-only and registry-only servers +96/-0

Add integration tests for resolver-only and registry-only servers

• Adds tests ensuring resolver-only mode keeps resolver endpoints reachable while registry routes are absent (404). Adds tests ensuring registry-only mode still proxies packument reads while resolver endpoints (including '/-/pnpr') cleanly 404.

pnpr/crates/pnpr/tests/server.rs

Documentation (1) +7 / -0
config.yamlDocument new 'registry'/'resolver' feature blocks +7/-0

Document new 'registry'/'resolver' feature blocks

• Adds commented YAML examples describing the two independently toggleable surfaces and their defaults. Notes that '/-/ping' is always served and at least one surface must remain enabled.

pnpr/crates/pnpr/config.yaml

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (1) 📜 Skill insights (0)

Context used

Grey Divider


Action required

1. Bundled override panic ✓ Resolved 🐞 Bug ☼ Reliability
Description
Config::resolve_with_overrides() can panic (instead of returning a config error) when both
surfaces are disabled via CLI and pnpr falls back to the bundled DEFAULT_CONFIG_YAML, because
from_default_yaml_with_overrides() uses .expect(...) on a now-fallible parse path. This turns a
normal misconfiguration into an uncontrolled crash on startup.
Code

pnpr/crates/pnpr/src/config.rs[R1069-1076]

+        Self::from_yaml_str_with_overrides(
+            DEFAULT_CONFIG_YAML,
+            base_dir,
+            listen,
+            public_url,
+            overrides,
+        )
+        .expect("bundled DEFAULT_CONFIG_YAML must always parse")
Evidence
The bundled-config path unconditionally panics on any error, but the new parse logic can now
legitimately return an error when both features are disabled via overrides; the CLI wires those
overrides into resolve_with_overrides, so the panic is user-reachable.

pnpr/crates/pnpr/src/config.rs[1040-1077]
pnpr/crates/pnpr/src/config.rs[1168-1256]
pnpr/crates/pnpr/src/main.rs[68-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`from_default_yaml_with_overrides()` uses `.expect("bundled DEFAULT_CONFIG_YAML must always parse")`, but the parse pipeline now calls `ensure_a_feature_is_enabled()` which can return an error when CLI overrides disable both surfaces. When no config file is present, `resolve_with_overrides()` uses the bundled-config path and therefore panics instead of returning `RegistryError::InvalidConfig`.
### Issue Context
The CLI passes `FeatureOverrides` into `Config::resolve_with_overrides()`. If a user runs with both `--disable-registry` and `--disable-resolver` and no config file is found, this currently becomes a panic rather than a clean startup error.
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1063-1076]
- pnpr/crates/pnpr/src/config.rs[1128-1146]
- pnpr/crates/pnpr/src/config.rs[1168-1244]
### Suggested direction
- Make `from_default_yaml_with_overrides` return `Result<Self, RegistryError>` (or an `io::Result<Self>`) and propagate the error through `resolve_with_overrides` instead of calling `expect`.
- Alternatively, perform an explicit `overrides` sanity check (both disabled) before calling the bundled-config path and return an `InvalidConfig` error (but prefer general error propagation so future parse failures don’t panic).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. CLI override loads uplink secrets ✓ Resolved 🐞 Bug ⛨ Security
Description
--disable-registry is applied after Config::resolve(), so YAML parsing may still resolve strict
uplink.auth tokens (and fail on missing token_env) even though the registry surface is later
disabled. This breaks the resolver-only tier’s “no upstream secrets needed/kept” property and can
unexpectedly block startup or retain registry credentials in memory on a supposedly stateless
resolver node.
Code

pnpr/crates/pnpr/src/main.rs[R110-118]

+    // Only flip a surface off when its flag is present, so the config's
+    // `enabled` value still wins when the flag is absent.
+    if args.disable_registry {
+        config.registry.enabled = false;
+    }
+    if args.disable_resolver {
+        config.resolver.enabled = false;
+    }
+    config.ensure_a_feature_is_enabled().map_err(|err| miette::miette!("{err}"))?;
Evidence
The CLI only flips config.registry.enabled after Config::resolve() has already loaded and
validated YAML, while config load resolves uplinks strictly whenever the YAML-derived registry
feature is enabled; strict uplink auth resolution errors if token_env can’t be resolved.

pnpr/crates/pnpr/src/main.rs[69-121]
pnpr/crates/pnpr/src/config.rs[1081-1128]
pnpr/crates/pnpr/src/config.rs[607-619]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
CLI flags `--disable-registry` / `--disable-resolver` are applied after `Config::resolve()`. Because `Config::from_yaml_str()` resolves uplinks (including strict auth token resolution) based on the *config-file* `registry.enabled`, a resolver-only deployment that relies on the CLI override can still:
- fail config load if `uplink.auth.token_env` is unset, and/or
- eagerly resolve and retain upstream Authorization headers in memory even though registry routes won’t be mounted.
### Issue Context
The code comment in `Config::from_yaml_str()` explicitly intends to avoid forcing resolver-only tiers to carry upstream secrets, but the current ordering only achieves that when `registry.enabled: false` is present in YAML, not when it’s disabled via CLI.
### Fix Focus Areas
- pnpr/crates/pnpr/src/main.rs[69-121]
- pnpr/crates/pnpr/src/config.rs[1064-1147]
- pnpr/crates/pnpr/src/config.rs[607-626]
### Suggested fix
Introduce a “feature overrides” path so final surface enablement is known *before* resolving uplinks:
1. Extend `Config::resolve(...)` / `Config::from_yaml(...)` / `from_yaml_str(...)` to accept overrides like `disable_registry` and `disable_resolver` (or a small `FeatureOverrides` struct).
2. Compute the effective `registry_enabled` / `resolver_enabled` as:
- `effective_registry = file.registry.unwrap_or_default().enabled && !override.disable_registry`
- `effective_resolver = file.resolver.unwrap_or_default().enabled && !override.disable_resolver`
3. Gate uplink resolution on `effective_registry` (and similarly gate OSV loading/auth startup where applicable).
4. Keep the existing `ensure_a_feature_is_enabled()` check after applying overrides.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Feature typos silently ignored ✓ Resolved 🐞 Bug ⛨ Security
Description
registry:/resolver: blocks accept unknown keys and default enabled to true, so a typo like
registry: { enable: false } will be ignored and the surface will remain enabled. This can
unintentionally expose endpoints an operator believed were disabled.
Code

pnpr/crates/pnpr/src/config.rs[R901-910]

+/// Disk shape of a `registry:` / `resolver:` feature block. A bare
+/// `enabled` today; per-surface sub-feature keys can be added later. The
+/// field and the whole-block defaults are both `enabled: true`, so
+/// omitting the block — or writing `registry:` with no body — keeps the
+/// surface on.
+#[derive(Debug, Deserialize)]
+struct FeatureFile {
+    #[serde(default = "default_true")]
+    enabled: bool,
+}
Evidence
The config parser explicitly relies on not using deny_unknown_fields, and the new FeatureFile
defaults enabled to true; therefore an unknown/misspelled field inside the feature block will be
ignored and the feature will stay enabled.

pnpr/crates/pnpr/src/config.rs[781-827]
pnpr/crates/pnpr/src/config.rs[901-920]
pnpr/crates/pnpr/src/config/tests.rs[735-759]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `registry:` / `resolver:` feature blocks deserialize without rejecting unknown fields, while also defaulting `enabled` to `true`. This makes simple typos silently keep a surface enabled (potentially exposing registry/resolver endpoints unexpectedly).
### Issue Context
The config layer intentionally ignores unknown top-level sections for verdaccio-compat, but these feature toggles are pnpr-only and are used for security/operational scoping.
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[781-827]
- pnpr/crates/pnpr/src/config.rs[901-920]
- pnpr/crates/pnpr/src/config/tests.rs[735-759]
### Suggested fix
- Add `#[serde(deny_unknown_fields)]` to `FeatureFile` so misspelled keys (e.g. `enable`) fail fast.
- Add a unit test asserting that `registry: { enable: false }` (or any unknown key) produces `RegistryError::InvalidConfig`.
- Keep the existing behavior that `registry:` (null) and `registry: {}` both mean default-enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Resolver-only still loads auth ✓ Resolved 🐞 Bug ☼ Reliability
Description
Even with registry.enabled=false, serve()/serve_listener() still recover the publish journal
and load the auth backends (htpasswd + tokens.db) before binding, despite resolver handlers not
using auth. This makes the new resolver-only mode unexpectedly stateful and can fail startup (or
write credentials files) on read-only/ephemeral deployments.
Code

pnpr/crates/pnpr/src/server.rs[R235-259]

+    // The npm-registry surface: every packument/tarball read, publish,
+    // unpublish, dist-tag, search, and the user/login endpoint. When the
+    // feature is disabled, none of these routes are mounted — not merely
+    // hidden — so a resolver-only tier exposes no registry surface at all.
+    if registry_enabled {
+        router = router
+            // Batch publish: one request carrying many packages' publish
+            // documents. Not part of the standard npm registry API —
+            // `pnpm publish --batch` opts into it explicitly.
+            .route("/-/pnpm/v1/publish", put(serve_batch_publish))
+            .route("/{name}", get(get_packument_unscoped).put(put_one_segment))
+            .route("/{first}/{second}", get(get_two_segments).put(put_two_segments))
+            .route(
+                "/{first}/{second}/{third}",
+                get(get_three_segments).put(put_three_segments).delete(delete_three_segments),
+            )
+            .route("/{scope}/{name}/-/{filename}", get(get_tarball_scoped))
+            .route("/{a}/{b}/{c}/{d}", get(get_four_segments).delete(delete_four_segments))
+            .route(
+                "/{a}/{b}/{c}/{d}/{e}",
+                get(get_five_segments).put(put_five_segments).delete(delete_five_segments),
+            )
+            // Scoped tarball delete: `DELETE /@scope/name/-/<basename-version>.tgz/-rev/<rev>`
+            .route("/{a}/{b}/{c}/{d}/{e}/{f}", delete(delete_six_segments));
+    }
Evidence
The server now supports resolver-only routing, but startup still eagerly loads registry-only state.
AuthState::load can create tokens.db on disk, and resolver handlers do not perform any auth
checks, so this work is unnecessary and can break the resolver-only deployment goal.

pnpr/crates/pnpr/src/server.rs[316-329]
pnpr/crates/pnpr/src/journal.rs[197-205]
pnpr/crates/pnpr/src/auth.rs[145-203]
pnpr/crates/pnpr/src/auth.rs[477-496]
pnpr/crates/pnpr/src/server.rs[2089-2109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Resolver-only mode (registry disabled) still initializes registry-specific startup components (publish journal recovery and auth backends) even though no mounted routes use them.
## Issue Context
- `serve()` and `serve_listener()` always call journal recovery and `AuthState::load(...)` before binding.
- The resolver endpoints (`/v1/resolve`, `/v1/verify-lockfile`, `/-/pnpr`) do not consult auth.
- `TokenStore::open()` creates parent dirs and the SQLite file, so resolver-only may perform unexpected writes.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[316-354]
- pnpr/crates/pnpr/src/server.rs[175-179]
- pnpr/crates/pnpr/src/auth.rs[145-203]
- pnpr/crates/pnpr/src/auth.rs[477-496]
### Implementation sketch
- In `serve()` / `serve_listener()`: only run `recover_publish_journal()` and `AuthState::load(...)` when `config.registry.enabled` is true.
- When registry is disabled, pass `AuthState::in_memory()` (or omit auth initialization entirely if you refactor `AppState`).
- Consider also skipping other registry-only startup work when registry is disabled (e.g., anything that creates credential state on disk).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. get_packument_unscoped lacks OSV screening 📎 Requirement gap ⛨ Security
Description
The registry packument and tarball routes are mounted, but when an OSV DB is configured the registry
surface does not consult state.inner.osv_index to filter/mark vulnerable versions in packuments or
to refuse/block serving tarballs for OSV-known-vulnerable versions. This allows registry clients to
resolve to and fetch vulnerable package versions, violating the required cross-surface OSV
enforcement.
Code

pnpr/crates/pnpr/src/server.rs[245]

+            .route("/{name}", get(get_packument_unscoped).put(put_one_segment))
Evidence
Compliance requires OSV screening across all registry surfaces (packuments and tarballs) when an OSV
database is configured. The PR mounts both the packument and tarball registry routes, but neither
serve_packument nor serve_tarball references state.inner.osv_index: serve_packument
therefore cannot filter/mark vulnerable versions, and serve_tarball proceeds to serve tarballs
from cache or proxy/fetch from upstream without any OSV policy check; OSV is only wired into
resolver runtime initialization/endpoints, leaving registry handlers unenforced.

Apply OSV screening to registry packument serving (proxy and cache)
Ensure OSV screening is enforced across all package-content surfaces, not only resolver endpoints
pnpr/crates/pnpr/src/server.rs[235-246]
pnpr/crates/pnpr/src/server.rs[641-660]
pnpr/crates/pnpr/src/server.rs[2089-2099]
pnpr/crates/pnpr/src/server.rs[235-252]
pnpr/crates/pnpr/src/server.rs[695-748]
pnpr/crates/pnpr/src/server.rs[2089-2109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Registry packument and tarball endpoints are served without OSV screening. When an OSV database is configured, packument responses must omit or clearly mark OSV-vulnerable versions so clients cannot resolve to them, and tarball endpoints must refuse/block serving tarballs for OSV-known-vulnerable package versions to ensure cross-surface OSV enforcement.
## Issue Context
The router mounts registry routes (including packuments and tarballs), but OSV is only wired into resolver flows; the registry handler paths never consult `state.inner.osv_index`. As a result, `serve_packument` does not filter/mark vulnerable versions and `serve_tarball` serves from cache or proxies upstream without an OSV check.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[235-258]
- pnpr/crates/pnpr/src/server.rs[641-660]
- pnpr/crates/pnpr/src/server.rs[695-748]
- pnpr/crates/pnpr/src/server.rs[2089-2109]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Resolver-only 100MiB request DoS 🐞 Bug ⛨ Security
Description
In resolver-only mode (registry.enabled=false), the server still applies a global 100MiB
DefaultBodyLimit, while /v1/resolve and /v1/verify-lockfile buffer the full request body into
memory (axum::body::Bytes) and JSON-deserialize it. This enables straightforward
resource-exhaustion (memory/CPU) by sending near-100MiB bodies to resolver endpoints in the new
resolver-only tier configuration.
Code

pnpr/crates/pnpr/src/server.rs[R235-262]

+    // The npm-registry surface: every packument/tarball read, publish,
+    // unpublish, dist-tag, search, and the user/login endpoint. When the
+    // feature is disabled, none of these routes are mounted — not merely
+    // hidden — so a resolver-only tier exposes no registry surface at all.
+    if registry_enabled {
+        router = router
+            // Batch publish: one request carrying many packages' publish
+            // documents. Not part of the standard npm registry API —
+            // `pnpm publish --batch` opts into it explicitly.
+            .route("/-/pnpm/v1/publish", put(serve_batch_publish))
+            .route("/{name}", get(get_packument_unscoped).put(put_one_segment))
+            .route("/{first}/{second}", get(get_two_segments).put(put_two_segments))
+            .route(
+                "/{first}/{second}/{third}",
+                get(get_three_segments).put(put_three_segments).delete(delete_three_segments),
+            )
+            .route("/{scope}/{name}/-/{filename}", get(get_tarball_scoped))
+            .route("/{a}/{b}/{c}/{d}", get(get_four_segments).delete(delete_four_segments))
+            .route(
+                "/{a}/{b}/{c}/{d}/{e}",
+                get(get_five_segments).put(put_five_segments).delete(delete_five_segments),
+            )
+            // Scoped tarball delete: `DELETE /@scope/name/-/<basename-version>.tgz/-rev/<rev>`
+            .route("/{a}/{b}/{c}/{d}/{e}/{f}", delete(delete_six_segments));
+    }
+    router
.layer(DefaultBodyLimit::max(MAX_PUBLISH_BODY_BYTES))
// gzip metadata responses for clients that send `Accept-Encoding:
Evidence
The router always applies a 100MiB body limit even when registry routes are not mounted, and the
resolver handlers buffer and parse the entire request body; together this makes large-body requests
to resolver endpoints an effective resource-exhaustion path in resolver-only mode.

pnpr/crates/pnpr/src/server.rs[48-53]
pnpr/crates/pnpr/src/server.rs[181-262]
pnpr/crates/pnpr/src/server.rs[2089-2109]
pnpr/crates/pnpr/src/resolver.rs[202-220]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Resolver-only deployments still accept request bodies up to `MAX_PUBLISH_BODY_BYTES` (100MiB) because `DefaultBodyLimit::max(MAX_PUBLISH_BODY_BYTES)` is applied at the router level. Resolver endpoints (`/v1/resolve`, `/v1/verify-lockfile`) fully buffer request bodies (`Bytes`) and JSON-deserialize them, so a 100MiB limit is an avoidable DoS vector when the registry surface is disabled.
## Issue Context
- The large body limit is needed for publish APIs, but not for the resolver endpoints.
- With the new feature toggles, operators can run a resolver-only tier; that tier should not inherit publish-sized body limits.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[206-262]
### Suggested implementation direction
- Remove the single router-level `DefaultBodyLimit::max(MAX_PUBLISH_BODY_BYTES)`.
- Build separate sub-routers per surface and apply different body limits:
- Registry router: keep `MAX_PUBLISH_BODY_BYTES`.
- Resolver router: introduce a smaller `MAX_RESOLVER_BODY_BYTES` (pick a value based on expected max lockfile/request size) and apply it only to resolver routes.
- Merge the sub-routers into the top-level router, keeping other global layers (compression/trace) as-is.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Resolver-only validates unused backends 🐞 Bug ☼ Reliability
Description
When registry.enabled is false, config parsing still eagerly builds/validates registry-only
backends (e.g. s3 hosted-store via build_s3_store, and backend DB selection) and router
construction still instantiates Storage, even though resolver endpoints never consult storage/auth
backends. This can make a resolver-only tier fail startup on irrelevant config errors and may force
carrying S3/DB credentials for a surface that is intentionally not mounted.
Code

pnpr/crates/pnpr/src/config.rs[R1208-1234]

+        // Effective enablement folds the CLI overrides in here, so the
+        // registry-only work below (uplink resolution) is skipped whether
+        // the surface was disabled in the config file or on the command
+        // line.
+        let registry = RegistryFeature {
+            enabled: file.registry.unwrap_or_default().enabled && !overrides.disable_registry,
+        };
+        let resolver = ResolverFeature {
+            enabled: file.resolver.unwrap_or_default().enabled && !overrides.disable_resolver,
+        };
+        // Only the registry surface consults uplinks, and `resolve_uplink`
+        // is strict — a `uplink.auth` block with an unresolvable token is a
+        // config error. A resolver-only server mounts no registry routes,
+        // so skip resolution entirely; otherwise a registry-shaped config
+        // would force the resolver tier to carry upstream secrets it never
+        // uses.
+        let uplinks = if registry.enabled {
+            file.uplinks
+                .into_iter()
+                .map(|(name, uplink)| {
+                    let resolved = resolve_uplink::<SystemEnv>(&name, uplink)?;
+                    Ok((name, resolved))
+                })
+                .collect::<Result<IndexMap<_, _>, RegistryError>>()?
+        } else {
+            IndexMap::new()
+        };
Evidence
Config parsing currently performs S3/back-end validation before computing effective surface
enablement, so these validations run even when the registry surface will be disabled and no routes
will use them; meanwhile server startup explicitly skips registry-only auth/journal work when
registry.enabled is false, demonstrating these components are irrelevant in resolver-only mode.

pnpr/crates/pnpr/src/config.rs[1185-1217]
pnpr/crates/pnpr/src/config.rs[1218-1234]
pnpr/crates/pnpr/src/s3.rs[83-111]
pnpr/crates/pnpr/src/config.rs[1317-1357]
pnpr/crates/pnpr/src/server.rs[197-209]
pnpr/crates/pnpr/src/server.rs[211-231]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In resolver-only mode (`registry.enabled=false`), pnpr should avoid doing registry-only parse-time validation/initialization (S3 hosted store construction, backend DB selection validation) and avoid building unused storage state, since no mounted routes will use those components.
## Issue Context
Today `Config::from_yaml_str_with_overrides` always constructs `hosted_store` (including `build_s3_store`) and validates `backend` before it computes effective `registry.enabled`, and `router_with_auth_and_osv` always constructs `Storage::new(...)` even when the registry surface is disabled.
## Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1189-1254]
- pnpr/crates/pnpr/src/config.rs[1317-1357]
- pnpr/crates/pnpr/src/server.rs[203-231]
- pnpr/crates/pnpr/src/server.rs[211-241]
## Suggested approach
- Compute effective `registry`/`resolver` enablement earlier in `from_yaml_str_with_overrides` (right after deserializing `ConfigFile`).
- If `registry.enabled == false`, skip registry-only config validation/construction:
- Set `hosted_store` to a cheap/default variant (or make it `Option`), without calling `build_s3_store`.
- Set `backend` to a safe default (or `Option`) without validating `backend:` selection.
- In `router_with_auth_and_osv`, consider making `storage` optional/lazy and only constructing it when `registry_enabled` is true (or when a mounted surface actually uses it).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Resolver stubs block registry 🐞 Bug ≡ Correctness
Description
When resolver.enabled is false, the router mounts any() 404 stubs for /v1/resolve and
/v1/verify-lockfile, which prevents the registry's GET /{name}/{versionOrTag} route from ever
serving those literal paths (e.g. package v1 with dist-tag resolve). This makes the new
“registry-only” mode not fully npm-registry compatible for those valid request paths.
Code

pnpr/crates/pnpr/src/server.rs[R270-273]

+        router = router
+            .route("/-/pnpr", any(resolver_disabled))
+            .route("/v1/resolve", any(resolver_disabled))
+            .route("/v1/verify-lockfile", any(resolver_disabled));
Evidence
The PR mounts any(resolver_disabled) on /v1/resolve and /v1/verify-lockfile when the resolver
is disabled. Separately, the registry surface serves version manifests for any unscoped `GET
/{first}/{second} by calling serve_version_manifest(first, second)`, so the literal path
/v1/resolve is a valid request for package v1 with version_or_tag = resolve, which becomes
unreachable due to the stubs.

pnpr/crates/pnpr/src/server.rs[243-274]
pnpr/crates/pnpr/src/server.rs[478-491]
pnpr/crates/pnpr/src/server.rs[724-754]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In resolver-disabled mode, pnpr registers `any(resolver_disabled)` stubs on `/v1/resolve` and `/v1/verify-lockfile`. Those are also valid npm registry version-manifest paths (`GET /:pkg/:tagOrVersion`), so the stubs can break legitimate requests in registry-only deployments.
## Issue Context
- The registry surface uses `GET /{first}/{second}` to serve version manifests for unscoped packages.
- With resolver disabled, exact-path stubs take precedence and return 404 for **all methods**, including GET.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[243-274]
- pnpr/crates/pnpr/src/server.rs[478-491]
### Suggested implementation direction
- Keep the `/-/pnpr` 404 stub (it overlaps the registry catch-all and is a real capability probe).
- For `/v1/resolve` and `/v1/verify-lockfile`, avoid stubbing **GET** so `GET /v1/resolve` can still function as a registry version-manifest path when desired.
- Option A: only stub the resolver’s actual verbs (POST) for those paths.
- Option B: remove the stubs for those paths entirely and rely on “route absent” behavior for POST (registry has no POST there).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Client registry SSRF 🐞 Bug ⛨ Security
Description
POST /v1/resolve uses client-provided registry / namedRegistries values as the base URLs for
server-side HTTP metadata fetches, with no allowlist or network-range restrictions, enabling SSRF if
the resolver surface is reachable by untrusted clients. The new resolver-only deployment mode
increases the likelihood of exposing these endpoints as a standalone tier, amplifying the impact.
Code

pnpr/crates/pnpr/src/server.rs[R249-253]

+    // The install-accelerator (resolver) surface. `/-/pnpr` is the
+    // capability handshake (404 on a plain registry); `/v1/resolve` and
+    // `/v1/verify-lockfile` are the resolver endpoints. These resolve
+    // against the registries the *client* sends, so the accelerator works
+    // whether or not this process also fronts a registry.
Evidence
The resolver handlers accept client-provided registry configuration, then propagate it into
pacquet’s config and use it to construct the URL for metadata fetches. Pacquet then performs an HTTP
GET to that constructed URL, so an attacker controlling registry can direct outbound requests to
arbitrary hosts; serve_resolve shows no auth enforcement on the endpoint itself.

pnpr/crates/pnpr/src/server.rs[2151-2162]
pnpr/crates/pnpr/src/resolver.rs[150-180]
pnpr/crates/pnpr/src/resolver.rs[216-230]
pacquet/crates/resolving-npm-resolver/src/registry_url.rs[24-31]
pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[88-106]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The resolver surface resolves against registries supplied by the client request and performs outbound HTTP GETs to URLs derived from those inputs. If `/v1/resolve` is accessible to untrusted clients, this is an SSRF primitive (probing internal hosts, metadata services, etc.).
## Issue Context
- `serve_resolve` does not enforce an auth gate before calling `handle_resolve`.
- `Resolver::config_for` sets pacquet’s `config.registry` and `config.named_registries` directly from request fields.
- Pacquet’s npm resolver builds the metadata URL as `<registry>/<encoded-pkg>` and issues `client.get(url)`.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[2151-2162]
- pnpr/crates/pnpr/src/resolver.rs[150-199]
- pnpr/crates/pnpr/src/resolver.rs[216-230]
- pacquet/crates/resolving-npm-resolver/src/registry_url.rs[24-31]
- pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[88-106]
### Suggested implementation direction
- Add a resolver-surface policy (e.g. `resolver.allowed_registries` / `resolver.allowed_hosts`), and reject requests whose `registry` / `namedRegistries` are not in that allowlist.
- At minimum, validate scheme is `https` (or explicitly configured `http`), and block loopback/link-local/private IP ranges and hostnames like `localhost` unless explicitly allowed.
- Consider requiring an auth token for resolver endpoints in deployments intended to serve multiple tenants.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (6)
10. Toggle key typos ignored 🐞 Bug ⛨ Security
Description
Top-level YAML key typos for registry:/resolver: are silently dropped because ConfigFile
intentionally does not deny unknown fields, so both surfaces can remain default-enabled. This can
unintentionally expose endpoints an operator believed were disabled, despite the new per-block
deny_unknown_fields protection.
Code

pnpr/crates/pnpr/src/config.rs[R914-928]

+/// Disk shape of a `registry:` / `resolver:` feature block. A bare
+/// `enabled` today; per-surface sub-feature keys can be added later. The
+/// field and the whole-block defaults are both `enabled: true`, so
+/// omitting the block — or writing `registry:` with no body — keeps the
+/// surface on.
+/// `deny_unknown_fields` so a typo like `registry: { enable: false }`
+/// (note: `enable`, not `enabled`) is a loud config error rather than
+/// silently leaving the surface enabled — these toggles scope which
+/// endpoints are exposed, so a silent default-on is a security footgun.
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct FeatureFile {
+    #[serde(default = "default_true")]
+    enabled: bool,
+}
Evidence
ConfigFile explicitly relies on *not* setting deny_unknown_fields so unsupported top-level
sections are accepted and dropped, and the test suite confirms unknown sections must parse silently.
At the same time, the new feature-block docs call silent default-on a security footgun, but that
protection only applies within the block, not to misspelled block names.

pnpr/crates/pnpr/src/config.rs[793-840]
pnpr/crates/pnpr/src/config.rs[914-928]
pnpr/crates/pnpr/src/config/tests.rs[802-827]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ConfigFile` parsing intentionally allows unknown top-level YAML keys (for verdaccio compatibility). With the new security-sensitive `registry:`/`resolver:` toggles defaulting to enabled, a misspelled top-level key (e.g. `regsitry:`) will be silently ignored and the surface will stay enabled.
### Issue Context
- `FeatureFile` uses `deny_unknown_fields` to make typos *inside* `registry:`/`resolver:` blocks loud.
- However, typos of the **block name itself** are still treated as unknown top-level sections and ignored.
- The test suite already asserts unknown sections are ignored, so a non-breaking fix should preserve compatibility (don’t fail on all unknown keys).
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[793-840]
- pnpr/crates/pnpr/src/config.rs[914-928]
- pnpr/crates/pnpr/src/config/tests.rs[802-827]
### Suggested implementation direction
- Capture unknown top-level keys during YAML deserialization (e.g., add a `#[serde(flatten)] extra: IndexMap<String, serde_saphyr::Value>` field on `ConfigFile`).
- After parsing, emit a targeted warning/error when `extra` contains keys that are likely typos of `registry`/`resolver` (at minimum, keys starting with `regis`/`resol`, or exact common misspellings), while continuing to silently ignore known verdaccio keys (`web`, `plugins`, etc.) to preserve compatibility.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Router helpers skip validation ✓ Resolved 🐞 Bug ☼ Reliability
Description
The new “at least one of registry/resolver must be enabled” invariant is enforced in YAML loading
and serve()/serve_listener(), but not in the public
router()/try_router()/router_with_auth() helpers. Embedders can therefore construct a Config
programmatically with both surfaces disabled and still obtain a Router that serves only /-/ping
(and possibly resolver 404 stubs), contrary to the intended invariant.
Code

pnpr/crates/pnpr/src/server.rs[R175-179]

/// Fallible counterpart to [`router_with_auth`].
pub fn try_router_with_auth(config: Config, auth: AuthState) -> crate::error::Result<Router> {
-    let osv_index = crate::resolver::load_osv_index(&config)?;
+    let osv_index = load_active_osv_index(&config)?;
Ok(router_with_auth_and_osv(config, auth, osv_index))
}
Evidence
serve()/serve_listener() explicitly enforce the invariant, but the router helper functions that
embedders can call directly do not; try_router_with_auth immediately proceeds to load OSV
(optionally) and build routes without validating feature enablement.

pnpr/crates/pnpr/src/server.rs[142-179]
pnpr/crates/pnpr/src/server.rs[352-390]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public router-construction APIs (`router`, `try_router`, `router_with_auth`, `try_router_with_auth`) do not call `Config::ensure_a_feature_is_enabled()`. After this PR introduces a hard invariant that at least one surface must be enabled, these APIs provide an escape hatch for embedders who build and serve the router themselves.
### Issue Context
`serve()`/`serve_listener()` now enforce the invariant explicitly for programmatic configs, but the router builders are also documented as embedder-facing and can be used to serve pnpr without going through `serve()`.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[142-179]
- pnpr/crates/pnpr/src/server.rs[352-391]
### Suggested fix
Add an invariant check early in the fallible router builder, e.g.:
- In `try_router_with_auth(config, auth)`: `config.ensure_a_feature_is_enabled()?;` before loading OSV / building the router.
Then `router_with_auth()` will also panic (via its `expect`) on a both-disabled config, matching the new invariant.
Optionally update doc comments to mention this invariant for embedders.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Missing feature validation in serve ✓ Resolved 🐞 Bug ☼ Reliability
Description
serve() and serve_listener() never call Config::ensure_a_feature_is_enabled(), so library
callers can start pnpr with both registry.enabled=false and resolver.enabled=false despite the
new invariant. This can silently create a server that only serves /-/ping (and 404 stubs), making
misconfiguration harder to detect outside the CLI path.
Code

pnpr/crates/pnpr/src/server.rs[R357-362]

pub async fn serve(config: Config) -> crate::error::Result<()> {
-    crate::journal::recover_publish_journal(&config).await?;
-    let osv_index = crate::resolver::load_osv_index(&config)?;
-    let auth = AuthState::load(&config.auth, &config.backend).await?;
+    let osv_index = load_active_osv_index(&config)?;
+    let auth = load_startup_auth(&config).await?;
let listen = config.listen;
let app = router_with_auth_and_osv(config, auth, osv_index);
let listener = NodelayTcpListener(tokio::net::TcpListener::bind(listen).await?);
Evidence
The invariant exists and is enforced for YAML/CLI flows, but the server entrypoints used by other
crates bypass it entirely, allowing an invalid both-disabled configuration to run.

pnpr/crates/pnpr/src/config.rs[1141-1151]
pnpr/crates/pnpr/src/main.rs[110-121]
pnpr/crates/pnpr/src/server.rs[352-391]
pacquet/crates/testing-utils/src/registry.rs[62-71]
pacquet/crates/cli/tests/pnpr_install.rs[36-48]
pacquet/crates/pnpr-client/tests/integration.rs[25-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Config::ensure_a_feature_is_enabled()` is enforced during YAML config load and in `main.rs` after CLI overrides, but the public server entrypoints `serve()` / `serve_listener()` do not enforce it. That means embedders/tests calling `pnpr::serve_listener` directly can accidentally run a “both surfaces disabled” server, contrary to the new contract.
## Issue Context
This repo already uses `pnpr::serve_listener` from other crates (e.g. pacquet tests/utilities), so the invariant should be enforced defensively at the server entrypoints too.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[352-391]
- pnpr/crates/pnpr/src/config.rs[1141-1151]
## Suggested change
At the start of `serve()` and `serve_listener()`, add:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Uplinks validated when unused ✓ Resolved 🐞 Bug ☼ Reliability
Description
Config::from_yaml_str still resolves and validates all uplinks (including requiring an auth
token for any uplink.auth) even when registry.enabled=false and no registry surface is mounted.
This can make resolver-only deployments fail at startup unless upstream registry secrets (e.g.
NPM_TOKEN) are present, undermining the new surface toggles.
Code

pnpr/crates/pnpr/src/config.rs[R1107-1114]

let logs = build_log_config(file.log.as_ref());
let policies = build_policies(&file.packages)?;
let osv = build_osv_config(&file.osv, base_dir);
+        let registry = RegistryFeature { enabled: file.registry.unwrap_or_default().enabled };
+        let resolver = ResolverFeature { enabled: file.resolver.unwrap_or_default().enabled };
let uplinks = file
.uplinks
.into_iter()
Evidence
Config parsing currently resolves uplinks unconditionally (and resolve_uplink hard-errors on
missing auth tokens), but the server-side routing/startup path explicitly avoids constructing
upstream clients when the registry surface is disabled, proving uplinks are unused in resolver-only
mode and should not block startup.

pnpr/crates/pnpr/src/config.rs[1081-1139]
pnpr/crates/pnpr/src/config.rs[607-669]
pnpr/crates/pnpr/src/server.rs[214-227]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When `registry.enabled` is `false`, pnpr mounts no registry routes and constructs no upstream clients, but config loading still resolves/validates all `uplinks` (including failing config load if `uplink.auth` has no resolvable token). This prevents running resolver-only tiers without carrying registry/uplink secrets.
### Issue Context
- Resolver-only mode intentionally avoids building upstream clients and mounting registry routes.
- `resolve_uplink()` is strict and returns `InvalidConfig` if an `auth:` block has no token.
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1081-1139]
- pnpr/crates/pnpr/src/config.rs[607-669]
- pnpr/crates/pnpr/src/server.rs[214-227]
### Suggested fix
- In `Config::from_yaml_str`, parse `registry`/`resolver` toggles early, then conditionally resolve uplinks:
- If `registry.enabled` is `true`: keep current strict `resolve_uplink()` behavior.
- If `registry.enabled` is `false`: set `uplinks` to an empty map (or defer resolution) and avoid failing on missing `uplink.auth` tokens.
- (Optional follow-on) Similarly consider skipping other registry-only validation (e.g., backend selection errors) when `registry.enabled` is `false`, to fully support resolver-only startup with a registry-shaped config file.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Resolver GET falls through ✓ Resolved 🐞 Bug ≡ Correctness
Description
When resolver.enabled is false, /v1/resolve and /v1/verify-lockfile are only stubbed for POST,
so a GET to those paths can still match the registry catch-all GET routes (e.g. /{first}/{second})
and enter registry/version-manifest handling instead of returning a clean 404.
Code

pnpr/crates/pnpr/src/server.rs[R256-260]

+        router = router
+            .route("/-/pnpr", get(resolver_disabled))
+            .route("/v1/resolve", post(resolver_disabled))
+            .route("/v1/verify-lockfile", post(resolver_disabled));
+    }
Evidence
In router_with_auth_and_osv, the resolver-disabled branch only stubs POST for /v1/resolve and
/v1/verify-lockfile. When the registry surface is enabled, the router also mounts `GET
/{first}/{second} which routes non-scoped two-segment GETs into serve_version_manifest, so GET
/v1/resolve` can be handled by registry logic instead of the intended 404 stub.

pnpr/crates/pnpr/src/server.rs[232-260]
pnpr/crates/pnpr/src/server.rs[265-284]
pnpr/crates/pnpr/src/server.rs[443-456]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When the resolver feature is disabled, the code stubs only `POST /v1/resolve` and `POST /v1/verify-lockfile`. `GET /v1/resolve` (and potentially other non-POST methods) can still be routed via the registry surface’s catch-all GET routes and trigger registry logic/upstream proxying, undermining the “resolver disabled” contract.
## Issue Context
The router intentionally mounts explicit 404 stubs when the resolver is disabled to prevent resolver probes from falling into the registry catch-all routes.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[232-260]
- pnpr/crates/pnpr/tests/server.rs[957-999]
## Proposed fix
- Change the disabled-resolver stubs to cover *all methods* for the resolver paths (e.g. use `axum::routing::any(resolver_disabled)` for `/-/pnpr`, `/v1/resolve`, and `/v1/verify-lockfile`, or explicitly register GET+POST+HEAD as appropriate).
- Add a regression test asserting that `GET /v1/resolve` and `GET /v1/verify-lockfile` return 404 when `resolver.enabled=false` (not 200/502/anything routed through the registry handlers).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Unneeded uplinks initialized ✓ Resolved 🐞 Bug ➹ Performance
Description
router_with_auth_and_osv constructs an Upstream (including a new ThrottledClient) for every
configured uplink even when registry.enabled is false. In resolver-only mode these upstream
clients are unused, adding avoidable startup time and memory overhead.
Code

pnpr/crates/pnpr/src/server.rs[R216-220]

.iter()
.map(|(name, uplink)| (name.clone(), Upstream::new(name, uplink)))
.collect();
+    let registry_enabled = config.registry.enabled;
+    let resolver_enabled = config.resolver.enabled;
Evidence
The router always builds upstreams from config.uplinks before checking registry_enabled, and
Upstream::new allocates a new ThrottledClient per uplink, so resolver-only mode pays startup
overhead for unused clients.

pnpr/crates/pnpr/src/server.rs[207-225]
pnpr/crates/pnpr/src/upstream.rs[206-222]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
In resolver-only deployments (`registry.enabled=false`), the router still eagerly builds `Upstream` instances for every configured uplink. Each `Upstream::new` creates a `ThrottledClient`, so this is avoidable startup work/memory for a mode that never mounts registry routes.
### Issue Context
The PR introduces resolver-only mode explicitly to support a stateless/lean tier. Avoiding unused client construction aligns with that goal.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[207-225]
- pnpr/crates/pnpr/src/upstream.rs[206-222]
### Suggested fix
- Compute `registry_enabled` before building `upstreams`.
- If `registry_enabled` is false, set `upstreams` to an empty `IndexMap` (or lazily initialize only when registry routes are mounted).
- Keep behavior unchanged for registry-enabled mode.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

16. OSV router docs outdated 🐞 Bug ⚙ Maintainability ⭐ New
Description
try_router()/router_with_auth() docs still claim OSV DB load failures occur whenever
osv.enabled is set, but OSV loading is now gated on resolver.enabled, which can mislead
embedders/operators about what is validated at router construction time.
Code

pnpr/crates/pnpr/src/server.rs[R185-195]

+/// Load the OSV index only for surfaces that actually consult it. Today
+/// that is the resolver, so a registry-only server (`resolver.enabled =
+/// false`) skips the load — avoiding the startup cost and the
+/// missing/invalid-database error for a feature no mounted route uses.
+/// The gate widens to the registry once registry-side OSV screening
+/// lands (pnpm/pnpm#12561).
+fn load_active_osv_index(
+    config: &Config,
+) -> crate::error::Result<Option<Arc<crate::resolver::OsvIndex>>> {
+    if config.resolver.enabled { crate::resolver::load_osv_index(config) } else { Ok(None) }
+}
Evidence
The router docs describe OSV loading as dependent only on osv.enabled, but the newly-added
load_active_osv_index() returns Ok(None) whenever resolver.enabled is false, so OSV is no
longer loaded/validated in that mode even if osv.enabled is true.

[pnpr/crates/pnpr/src/server.rs[157-183]](https://github.com/pnpm/pnpm/blob/e56c000f0ed29fc6ef9249162c6e0f971a1c9057/pnpr/crates/pnpr/src/serv...

// documents. Not part of the standard npm registry API —
// `pnpm publish --batch` opts into it explicitly.
.route("/-/pnpm/v1/publish", put(serve_batch_publish))
.route("/{name}", get(get_packument_unscoped).put(put_one_segment))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. get_packument_unscoped lacks osv screening 📎 Requirement gap ⛨ Security

The registry packument and tarball routes are mounted, but when an OSV DB is configured the registry
surface does not consult state.inner.osv_index to filter/mark vulnerable versions in packuments or
to refuse/block serving tarballs for OSV-known-vulnerable versions. This allows registry clients to
resolve to and fetch vulnerable package versions, violating the required cross-surface OSV
enforcement.
Agent Prompt
## Issue description
Registry packument and tarball endpoints are served without OSV screening. When an OSV database is configured, packument responses must omit or clearly mark OSV-vulnerable versions so clients cannot resolve to them, and tarball endpoints must refuse/block serving tarballs for OSV-known-vulnerable package versions to ensure cross-surface OSV enforcement.

## Issue Context
The router mounts registry routes (including packuments and tarballs), but OSV is only wired into resolver flows; the registry handler paths never consult `state.inner.osv_index`. As a result, `serve_packument` does not filter/mark vulnerable versions and `serve_tarball` serves from cache or proxies upstream without an OSV check.

## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[235-258]
- pnpr/crates/pnpr/src/server.rs[641-660]
- pnpr/crates/pnpr/src/server.rs[695-748]
- pnpr/crates/pnpr/src/server.rs[2089-2109]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the pre-existing registry-side OSV screening gap: the registry serving path has never consulted osv_index, and this PR doesn't change that behavior — it only gates which surfaces are mounted. The expected behavior (omit/mark OSV-vulnerable versions in served packuments and refuse vulnerable tarballs, for both proxy and cache) is tracked in #12561. Deferring to that issue rather than widening this PR's scope.


Written by an agent (Claude Code, claude-opus-4-8).

Comment on lines +235 to 262
// The npm-registry surface: every packument/tarball read, publish,
// unpublish, dist-tag, search, and the user/login endpoint. When the
// feature is disabled, none of these routes are mounted — not merely
// hidden — so a resolver-only tier exposes no registry surface at all.
if registry_enabled {
router = router
// Batch publish: one request carrying many packages' publish
// documents. Not part of the standard npm registry API —
// `pnpm publish --batch` opts into it explicitly.
.route("/-/pnpm/v1/publish", put(serve_batch_publish))
.route("/{name}", get(get_packument_unscoped).put(put_one_segment))
.route("/{first}/{second}", get(get_two_segments).put(put_two_segments))
.route(
"/{first}/{second}/{third}",
get(get_three_segments).put(put_three_segments).delete(delete_three_segments),
)
.route("/{scope}/{name}/-/{filename}", get(get_tarball_scoped))
.route("/{a}/{b}/{c}/{d}", get(get_four_segments).delete(delete_four_segments))
.route(
"/{a}/{b}/{c}/{d}/{e}",
get(get_five_segments).put(put_five_segments).delete(delete_five_segments),
)
// Scoped tarball delete: `DELETE /@scope/name/-/<basename-version>.tgz/-rev/<rev>`
.route("/{a}/{b}/{c}/{d}/{e}/{f}", delete(delete_six_segments));
}
router
.layer(DefaultBodyLimit::max(MAX_PUBLISH_BODY_BYTES))
// gzip metadata responses for clients that send `Accept-Encoding:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Resolver-only 100mib request dos 🐞 Bug ⛨ Security

In resolver-only mode (registry.enabled=false), the server still applies a global 100MiB
DefaultBodyLimit, while /v1/resolve and /v1/verify-lockfile buffer the full request body into
memory (axum::body::Bytes) and JSON-deserialize it. This enables straightforward
resource-exhaustion (memory/CPU) by sending near-100MiB bodies to resolver endpoints in the new
resolver-only tier configuration.
Agent Prompt
## Issue description
Resolver-only deployments still accept request bodies up to `MAX_PUBLISH_BODY_BYTES` (100MiB) because `DefaultBodyLimit::max(MAX_PUBLISH_BODY_BYTES)` is applied at the router level. Resolver endpoints (`/v1/resolve`, `/v1/verify-lockfile`) fully buffer request bodies (`Bytes`) and JSON-deserialize them, so a 100MiB limit is an avoidable DoS vector when the registry surface is disabled.

## Issue Context
- The large body limit is needed for publish APIs, but not for the resolver endpoints.
- With the new feature toggles, operators can run a resolver-only tier; that tier should not inherit publish-sized body limits.

## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[206-262]

### Suggested implementation direction
- Remove the single router-level `DefaultBodyLimit::max(MAX_PUBLISH_BODY_BYTES)`.
- Build separate sub-routers per surface and apply different body limits:
  - Registry router: keep `MAX_PUBLISH_BODY_BYTES`.
  - Resolver router: introduce a smaller `MAX_RESOLVER_BODY_BYTES` (pick a value based on expected max lockfile/request size) and apply it only to resolver routes.
- Merge the sub-routers into the top-level router, keeping other global layers (compression/trace) as-is.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 100 MiB DefaultBodyLimit and the resolver endpoints' full-body buffering both pre-date this PR — /v1/resolve and /v1/verify-lockfile already carried the same global limit, so this isn't a regression introduced here. A per-surface limit is reasonable hardening, but the resolver legitimately accepts large bodies (a full lockfile plus every workspace member's manifest), so the cap needs a considered value rather than an arbitrary one. Tracking as a follow-up rather than bundling it into this change.


Written by an agent (Claude Code, claude-opus-4-8).

Comment thread pnpr/crates/pnpr/src/config.rs
@github-actions github-actions Bot added the reviewed: coderabbit CodeRabbit submitted an approving review label Jun 21, 2026
Comment thread pnpr/crates/pnpr/src/server.rs
Comment thread pnpr/crates/pnpr/src/server.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit d9e63ca

@codecov-commenter

codecov-commenter commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.14894% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.07%. Comparing base (b4fdfe9) to head (e56c000).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/main.rs 0.00% 6 Missing ⚠️
pnpr/crates/pnpr/src/server.rs 92.75% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12563      +/-   ##
==========================================
+ Coverage   88.02%   88.07%   +0.05%     
==========================================
  Files         321      321              
  Lines       44941    45078     +137     
==========================================
+ Hits        39559    39704     +145     
+ Misses       5382     5374       -8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…re blocks

Addresses PR review:

- A bare `registry:` / `resolver:` block parses as YAML null, which could
  not deserialize into the non-Option `FeatureFile` and would fail config
  load despite the documented "empty block means enabled". The fields are
  now `Option<FeatureFile>`, defaulting to enabled.
- Resolver-only mode (registry disabled) no longer recovers the publish
  journal or loads on-disk auth backends, so the resolver tier stays
  stateless and runs on read-only/ephemeral hosts.
- Registry-only mode (resolver disabled) no longer loads the OSV index,
  since only resolver routes consult it today. The gate widens once
  registry-side OSV screening lands (#12561).
Comment thread pnpr/crates/pnpr/src/config.rs
Comment thread pnpr/crates/pnpr/src/server.rs Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2623445

Comment thread pnpr/crates/pnpr/src/server.rs Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2623445

- Reject unknown keys in `registry:` / `resolver:` blocks
  (`deny_unknown_fields`), so a typo like `enable:` is a loud config
  error instead of silently leaving the surface enabled.
- When the resolver is disabled, stub its paths for every method
  (`any`), not just POST/GET, so a `GET /v1/resolve` probe can't fall
  through to the registry's catch-all routes and be proxied.
- Skip building uplink `Upstream` clients in resolver-only mode; only
  registry routes consult them.
Comment thread pnpr/crates/pnpr/src/config.rs Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 82b3093

Comment thread pnpr/crates/pnpr/src/server.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 82b3093

@github-actions

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.784 ± 0.186 3.533 4.082 1.82 ± 0.14
pacquet@main 3.715 ± 0.138 3.588 3.965 1.79 ± 0.13
pnpr@HEAD 2.076 ± 0.123 1.943 2.365 1.00
pnpr@main 2.151 ± 0.140 1.998 2.348 1.04 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.78411104856,
      "stddev": 0.18601871543814447,
      "median": 3.79318289036,
      "user": 3.51676402,
      "system": 3.39010192,
      "min": 3.5333442483599997,
      "max": 4.0819406233599995,
      "times": [
        4.01022764236,
        3.5787937473599998,
        3.61843603336,
        3.5333442483599997,
        3.89091484036,
        3.86448311936,
        3.67660445036,
        3.86217243636,
        3.7241933443599997,
        4.0819406233599995
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.7147523041599997,
      "stddev": 0.13818553839612083,
      "median": 3.68345239686,
      "user": 3.52963382,
      "system": 3.37100082,
      "min": 3.58793159136,
      "max": 3.96541519536,
      "times": [
        3.8231786243599997,
        3.69364091436,
        3.58793159136,
        3.5909769353599996,
        3.96541519536,
        3.91150050236,
        3.68897782336,
        3.59528583536,
        3.61268864936,
        3.6779269703599997
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.0757421134599996,
      "stddev": 0.12343229536315078,
      "median": 2.0585418748599995,
      "user": 2.63369912,
      "system": 2.9959905199999994,
      "min": 1.94335740636,
      "max": 2.36520852436,
      "times": [
        2.0304418003599998,
        1.9931122083600001,
        1.94335740636,
        2.0952892243599996,
        2.01735195236,
        2.0866419493599997,
        1.95619653736,
        2.36520852436,
        2.1055838153599997,
        2.1642377163599997
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1509354277600004,
      "stddev": 0.14013866973616249,
      "median": 2.1002297573599997,
      "user": 2.6342170200000004,
      "system": 2.98809942,
      "min": 1.9975044183600001,
      "max": 2.34825187936,
      "times": [
        2.14230686636,
        1.9975044183600001,
        2.05782086736,
        1.9993814933600003,
        2.34825187936,
        2.0581526483599997,
        2.0557250683599997,
        2.19461730436,
        2.34228299736,
        2.31331073436
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 641.8 ± 10.1 625.9 657.8 1.00
pacquet@main 681.4 ± 105.6 632.6 980.5 1.06 ± 0.17
pnpr@HEAD 722.7 ± 71.6 669.2 913.3 1.13 ± 0.11
pnpr@main 709.7 ± 54.3 672.1 859.3 1.11 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6418182510599999,
      "stddev": 0.010051146175356455,
      "median": 0.6397127194600001,
      "user": 0.39837648000000003,
      "system": 1.32626884,
      "min": 0.62591367546,
      "max": 0.65780609946,
      "times": [
        0.65780609946,
        0.63723401146,
        0.6545257304600001,
        0.6335717444600001,
        0.63727874646,
        0.64214669246,
        0.64622112046,
        0.6343244234600001,
        0.62591367546,
        0.64916026646
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6813926193600001,
      "stddev": 0.1055712760508469,
      "median": 0.6503207359600001,
      "user": 0.38886968000000005,
      "system": 1.3441092399999999,
      "min": 0.63260028246,
      "max": 0.98051342846,
      "times": [
        0.63260028246,
        0.64338392546,
        0.6505220324600001,
        0.64502082646,
        0.6501194394600001,
        0.6639722464600001,
        0.6544927084600001,
        0.63411984746,
        0.65918145646,
        0.98051342846
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.72265320836,
      "stddev": 0.07157459888585466,
      "median": 0.69496520046,
      "user": 0.4187642800000001,
      "system": 1.3539389400000001,
      "min": 0.66919496046,
      "max": 0.91329855546,
      "times": [
        0.74612214046,
        0.70897758946,
        0.68939440746,
        0.69627255146,
        0.6936578494600001,
        0.67958866046,
        0.68716547046,
        0.66919496046,
        0.91329855546,
        0.74285989846
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.70968261726,
      "stddev": 0.05425771264359275,
      "median": 0.69669788546,
      "user": 0.40417228000000005,
      "system": 1.3504490399999998,
      "min": 0.67207354046,
      "max": 0.85927088246,
      "times": [
        0.67207354046,
        0.7015908844600001,
        0.70875925746,
        0.69484764946,
        0.6854424344600001,
        0.6756587874600001,
        0.6866608954600001,
        0.85927088246,
        0.71397371946,
        0.69854812146
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.230 ± 0.040 4.153 4.294 1.93 ± 0.11
pacquet@main 4.236 ± 0.050 4.155 4.305 1.93 ± 0.11
pnpr@HEAD 2.197 ± 0.125 2.050 2.375 1.00
pnpr@main 2.206 ± 0.113 2.069 2.416 1.00 ± 0.08
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.2297486697,
      "stddev": 0.03997425407120895,
      "median": 4.231480659900001,
      "user": 3.7449470999999996,
      "system": 3.3720886799999996,
      "min": 4.152729931400001,
      "max": 4.294089464400001,
      "times": [
        4.2697239304000005,
        4.214309832400001,
        4.1949131444,
        4.214585432400001,
        4.261622586400001,
        4.2318188314,
        4.231142488400001,
        4.294089464400001,
        4.152729931400001,
        4.2325510554
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.2363411035,
      "stddev": 0.05021069584467642,
      "median": 4.251960626400001,
      "user": 3.7301562999999995,
      "system": 3.3939906799999995,
      "min": 4.154595121400001,
      "max": 4.3051655654,
      "times": [
        4.1704024094,
        4.2021089584,
        4.3051655654,
        4.2798165494000004,
        4.2526540254,
        4.205590426400001,
        4.2512672274000005,
        4.2666004064,
        4.154595121400001,
        4.2752103454000006
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1966845655,
      "stddev": 0.1245549287215409,
      "median": 2.1737197254,
      "user": 2.5043341999999997,
      "system": 2.9233930799999994,
      "min": 2.0497554044,
      "max": 2.3748175203999997,
      "times": [
        2.1280890273999997,
        2.3721547354,
        2.2223962324,
        2.0822586824,
        2.0497554044,
        2.2193504234,
        2.0827657104,
        2.1111213034,
        2.3241366154,
        2.3748175203999997
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.2060389271,
      "stddev": 0.11327467623630479,
      "median": 2.1701280983999998,
      "user": 2.5149003,
      "system": 2.91744538,
      "min": 2.0691793034,
      "max": 2.4163967304,
      "times": [
        2.2221225103999998,
        2.1281737634,
        2.1200077374,
        2.1639002614,
        2.0691793034,
        2.3912019954,
        2.2096849873999997,
        2.1763559353999997,
        2.4163967304,
        2.1633660463999997
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.355 ± 0.019 1.331 1.385 2.05 ± 0.04
pacquet@main 1.383 ± 0.021 1.354 1.415 2.09 ± 0.05
pnpr@HEAD 0.680 ± 0.062 0.648 0.854 1.03 ± 0.09
pnpr@main 0.660 ± 0.010 0.646 0.680 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3547591423000003,
      "stddev": 0.018940665656176678,
      "median": 1.3556966279,
      "user": 1.3569460999999998,
      "system": 1.7373884,
      "min": 1.3306710114,
      "max": 1.3854921444000001,
      "times": [
        1.3567457564,
        1.3570331554000001,
        1.3546474994000002,
        1.3655718104,
        1.3854921444000001,
        1.3339189164,
        1.3795341474,
        1.3306710114,
        1.3520792014,
        1.3318977804
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3825238044000003,
      "stddev": 0.02129703646379773,
      "median": 1.3871736419,
      "user": 1.3723512000000002,
      "system": 1.7796500999999996,
      "min": 1.3537755514,
      "max": 1.4154824994000001,
      "times": [
        1.3632296754000002,
        1.3896623634,
        1.3537755514,
        1.3608952064000002,
        1.4154824994000001,
        1.4108369584,
        1.3922845334,
        1.3846849204,
        1.3645458514000002,
        1.3898404844
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6802279774000001,
      "stddev": 0.061677160915642695,
      "median": 0.6625482954000002,
      "user": 0.3591016,
      "system": 1.2863685999999999,
      "min": 0.6479106784,
      "max": 0.8541173484000001,
      "times": [
        0.6531344494000001,
        0.6599870164000001,
        0.6479106784,
        0.8541173484000001,
        0.6651095744000001,
        0.6676680024000001,
        0.6672385534,
        0.6528021034000001,
        0.6581560804000001,
        0.6761559674000001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6604440957000002,
      "stddev": 0.009926648619702121,
      "median": 0.6595302739000001,
      "user": 0.36346599999999996,
      "system": 1.2778006999999998,
      "min": 0.6459449854000001,
      "max": 0.6797123634000001,
      "times": [
        0.6646221314,
        0.6590637234000001,
        0.6797123634000001,
        0.6615144414,
        0.6702242584000001,
        0.6471135844000001,
        0.6576090924000001,
        0.6459449854000001,
        0.6586395524,
        0.6599968244000001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.022 ± 0.093 2.959 3.281 4.59 ± 0.16
pacquet@main 3.018 ± 0.034 2.967 3.073 4.58 ± 0.08
pnpr@HEAD 0.671 ± 0.013 0.653 0.695 1.02 ± 0.02
pnpr@main 0.659 ± 0.009 0.645 0.672 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.02155928662,
      "stddev": 0.09320734944569661,
      "median": 2.99994719482,
      "user": 1.79050692,
      "system": 1.9637064799999997,
      "min": 2.95935628582,
      "max": 3.28066671782,
      "times": [
        2.97079634882,
        2.98385010182,
        3.0029421738199997,
        3.02372636382,
        3.00218886382,
        2.97847765782,
        2.99770552582,
        3.28066671782,
        3.01588282682,
        2.95935628582
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.01792057252,
      "stddev": 0.034095834592730266,
      "median": 3.0228185518200004,
      "user": 1.7933430199999996,
      "system": 1.95453968,
      "min": 2.9665029128200002,
      "max": 3.07312172682,
      "times": [
        3.03239892782,
        2.9665029128200002,
        2.99409877782,
        3.05505854982,
        2.97111943482,
        3.01555448682,
        3.03008261682,
        3.07312172682,
        3.03196713082,
        3.0093011608199998
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.67143447042,
      "stddev": 0.013357793082436883,
      "median": 0.6707865348200002,
      "user": 0.36120341999999994,
      "system": 1.26751868,
      "min": 0.6530197148200001,
      "max": 0.69473675582,
      "times": [
        0.6718370738200001,
        0.6663774628200001,
        0.6618796858200001,
        0.69473675582,
        0.6697359958200001,
        0.65410433582,
        0.6530197148200001,
        0.6760716968200001,
        0.6826313528200001,
        0.6839506298200001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6587389389200002,
      "stddev": 0.009311777291842172,
      "median": 0.6590412443200001,
      "user": 0.35992201999999995,
      "system": 1.27516308,
      "min": 0.6454392938200001,
      "max": 0.6718852848200001,
      "times": [
        0.6454392938200001,
        0.6636011808200001,
        0.66947257682,
        0.6718852848200001,
        0.6547488628200001,
        0.6669033888200001,
        0.6596881698200001,
        0.6472554188200002,
        0.6583943188200001,
        0.6500008938200001
      ]
    }
  ]
}

@github-actions

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12563
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,229.75 ms
(+0.05%)Baseline: 4,227.50 ms
5,073.00 ms
(83.38%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,021.56 ms
(+0.51%)Baseline: 3,006.25 ms
3,607.50 ms
(83.76%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,354.76 ms
(+1.55%)Baseline: 1,334.02 ms
1,600.82 ms
(84.63%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
3,784.11 ms
(-5.84%)Baseline: 4,018.67 ms
4,822.41 ms
(78.47%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
641.82 ms
(+2.17%)Baseline: 628.17 ms
753.81 ms
(85.14%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12563
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,196.68 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
671.43 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
680.23 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,075.74 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
722.65 ms
🐰 View full continuous benchmarking report in Bencher

- Skip uplink resolution/validation when the registry is disabled, so a
  resolver-only tier doesn't fail config load on a registry-shaped config
  whose uplink auth tokens (e.g. NPM_TOKEN) aren't present — it never
  mounts a route that uses them.
- Enforce the "at least one surface enabled" invariant in serve() and
  serve_listener(), not just at YAML load and in the CLI, so embedders
  calling these entrypoints directly can't start a ping-only server.
Comment thread pnpr/crates/pnpr/src/main.rs Outdated
Comment thread pnpr/crates/pnpr/src/server.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e95ae84

1 similar comment
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e95ae84

…uilders

Addresses PR review:

- `--disable-registry` / `--disable-resolver` are now threaded into
  config parsing via `FeatureOverrides` and `Config::resolve_with_overrides`,
  so a CLI-disabled surface skips its parse-time work (notably strict
  uplink auth-token resolution) — not just its routes. Previously the
  flags were applied after `resolve()`, so a resolver-only tier driven by
  the flag could still fail to load, or retain upstream secrets, from a
  registry-shaped config.
- The "at least one surface enabled" invariant is now also enforced in
  the public router builders (`try_router_with_auth`, and via it
  `router`/`try_router`/`router_with_auth`), so embedders that build a
  router directly can't get a ping-only server.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 20caa23

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pnpr/crates/pnpr/src/config.rs`:
- Around line 1069-1076: The `.expect()` call in the
`from_default_yaml_with_overrides` method causes a panic when
`from_yaml_str_with_overrides` returns an error (such as when both CLI disable
flags are set), while file-backed branches properly return `Err`. Replace the
`.expect()` call with the `?` operator to propagate the error, ensuring the
bundled DEFAULT_CONFIG_YAML branch handles errors consistently with file-backed
branches instead of panicking on invalid configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9fab8abf-59aa-4383-9b85-f2460c587f07

📥 Commits

Reviewing files that changed from the base of the PR and between e95ae84 and 20caa23.

📒 Files selected for processing (5)
  • pnpr/crates/pnpr/src/config.rs
  • pnpr/crates/pnpr/src/config/tests.rs
  • pnpr/crates/pnpr/src/lib.rs
  • pnpr/crates/pnpr/src/main.rs
  • pnpr/crates/pnpr/src/server.rs
✅ Files skipped from review due to trivial changes (1)
  • pnpr/crates/pnpr/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • pnpr/crates/pnpr/src/server.rs

Comment thread pnpr/crates/pnpr/src/config.rs Outdated
…onfig

`from_default_yaml_with_overrides` kept the `expect(...)` on the parse
result, so `--disable-registry --disable-resolver` with no config file
(the bundled branch) panicked on the "at least one surface" validation
error instead of returning it like the file-backed branches. It now
returns `Result`, `resolve_with_overrides` maps the error to an
io::Error, and the override-free `from_default_yaml` keeps the `expect`
(safe: default overrides leave both surfaces enabled).
Comment thread pnpr/crates/pnpr/src/config.rs Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 20caa23

Comment thread pnpr/crates/pnpr/src/config.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a9ea91e

1 similar comment
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a9ea91e

The config parser is intentionally lenient about unknown top-level keys
(so a verdaccio config can be loaded verbatim), which means a typo'd
`registry:` / `resolver:` block name is silently ignored and the surface
stays at its default-enabled state. Logging the effective surface state
at startup makes such a misconfiguration immediately visible instead of
only discoverable by probing endpoints.
Comment thread pnpr/crates/pnpr/src/server.rs Outdated
Comment thread pnpr/crates/pnpr/src/server.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 30e6d28

1 similar comment
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 30e6d28

…paths

When the resolver is disabled, only `/-/pnpr` is stubbed now — it's the
capability-probe path that overlaps the registry catch-all and must 404
cleanly for detection. `/v1/resolve` and `/v1/verify-lockfile` are no
longer stubbed: they are POST-only resolver endpoints with no capability
probe, and stubbing every method wrongly 404'd the legitimate npm
version-manifest paths `GET /v1/resolve` (package `v1`, tag `resolve`)
in registry-only mode. Left unmounted, the registry serves their GET as
usual and a POST falls to a 405.
@zkochan zkochan merged commit bed6c59 into main Jun 22, 2026
31 checks passed
@zkochan zkochan deleted the pnpr-feature-toggles branch June 22, 2026 00:01
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e56c000

1 similar comment
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e56c000

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

product: pnpr reviewed: coderabbit CodeRabbit submitted an approving review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants