Skip to content

feat(pnpr): derive the registry surface from declared mounts#12773

Merged
zkochan merged 6 commits into
mainfrom
issue-12767
Jul 2, 2026
Merged

feat(pnpr): derive the registry surface from declared mounts#12773
zkochan merged 6 commits into
mainfrom
issue-12767

Conversation

@zkochan

@zkochan zkochan commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Closes #12767.

Removes pnpr's top-level registry: config key and derives the npm-registry surface from declared structure instead: the surface is served iff at least one mount is declared under mounts:, minus the per-tier --disable-registry override. The resolver: toggle stays — it is the only expression of a genuinely non-derivable operator choice and duplicates nothing. A leftover registry: key in a config is simply ignored like any other unknown key (pnpr is pre-1.0; no compatibility shim).

  • Account endpoints move out of the registry surface. adduser/login, whoami, profile, token listing/revocation, and logout now live on dedicated routes mounted on every tier (path-less and under any /~<prefix>/), so a resolver-only tier mints and manages the tokens its own resolver surface demands: pnpm login --registry https://<resolver-host>/ works without a registry-serving replica sharing the auth backend. No new exposure — minting stays gated by auth.htpasswd.max_users and existing-user passwords, and token validation already ran on every tier.
  • The startup check becomes "nothing to serve": no mounts (or registry disabled by flag) and the resolver disabled is a config error. The mount graph is still built and validated on every tier, and a flag-disabled registry still skips strict upstream-credential resolution.

Verified end-to-end: a no-mounts config starts a resolver-only tier (registry=false resolver=true), serves /-/ping, the /-/pnpr handshake, login/whoami/token-list (also under /~corp/), and 404s packument reads; a mounts config derives registry=true and --disable-registry overrides it; a stale registry: {enabled: false} next to mounts is ignored and derivation wins; the nothing-to-serve config fails startup with a precise error.

Docs follow-up (pnpm.io): pnpr configuration.md (drop the registry: section), endpoints.md (move the user/token endpoint table out of the registry-gated group), cli.md (--disable-registry wording), and the install-acceleration login note.

Review follow-ups (ac932a09ab, 9efb0503ba): the anonymous-reachable adduser/login route now carries its own 64 KiB body cap instead of inheriting the 100 MiB publish ceiling, and the access log redacts the raw bearer token that npm's logout protocol places in the URL path (uri=/-/user/token/<redacted>).

Squash Commit Body

The top-level `registry:` config key duplicated information the config
already carries and could contradict it: `mounts:` together with
`registry: {enabled: false}` declared structure and then disclaimed it.
The npm-registry surface is now served iff at least one mount is
declared under `mounts:`, minus the per-tier `--disable-registry`
override. The `registry:` key is gone; a leftover one is ignored like
any other unknown key by the verdaccio-lenient parser. The `resolver:`
toggle stays: it is the only expression of a genuinely non-derivable
operator choice and duplicates nothing.

The account endpoints (adduser/login, whoami, profile, token listing
and revocation, logout) move out of the registry surface onto
dedicated always-mounted routes: they are pnpr account management, not
package-registry functionality, and a resolver-only tier must be able
to mint the tokens its own resolver surface demands
(`pnpm login --registry https://<resolver-host>/`).

The at-least-one-surface startup check becomes a nothing-to-serve
error: no mounts (or the registry disabled by flag) and the resolver
disabled. The mount graph is still built and validated on every tier,
and a registry surface disabled by flag still skips strict upstream
credential resolution.

Closes pnpm/pnpm#12767

Checklist

  • The change is implemented in both the TypeScript CLI and the Rust pacquet/ port, or the description notes what still needs porting. (pnpr-only server change; no pnpm/pacquet counterpart exists.)
  • Added or updated tests.
  • Updated the documentation if needed. (pnpm.io pnpr docs listed above need a follow-up PR in the pnpm.io repo once this lands.)

Written by an agent (Claude Code, claude-fable-5).

Summary by CodeRabbit

  • New Features
    • Account and token-related endpoints are now consistently available on every tier, with login requests capped to prevent oversized payloads.
  • Bug Fixes
    • npm-registry surface availability is derived from declared mounts; --disable-registry forces it off, with improved “nothing to serve” validation when resolver/registry can’t run.
    • HTTP access logs now redact logout token segments to avoid leaking bearer tokens.
  • Documentation
    • Updated configuration comments and --disable-registry CLI help to clarify mounts vs resolver/registry behavior.
  • Tests
    • Added oversized login-body rejection coverage and expanded authenticated endpoint/token-flow verification; added unit coverage for access-log redaction.

zkochan added 2 commits July 2, 2026 23:22
The top-level `registry:` config key duplicated information the config
already carries and could contradict it: `mounts:` together with
`registry: {enabled: false}` declared structure and then disclaimed it.
The npm-registry surface is now served iff at least one mount is
declared under `mounts:`, minus the per-tier `--disable-registry`
override. The removed `registry:` key is rejected with migration
guidance rather than silently ignored — the parser is lenient about
unknown keys for verdaccio compat, but silently dropping a key that
used to disable a surface would fail open. The `resolver:` toggle
stays: it is the only expression of a genuinely non-derivable operator
choice and duplicates nothing.

The account endpoints (adduser/login, whoami, profile, token listing
and revocation, logout) move out of the registry surface onto
dedicated always-mounted routes: they are pnpr account management, not
package-registry functionality, and a resolver-only tier must be able
to mint the tokens its own resolver surface demands
(`pnpm login --registry https://<resolver-host>/`).

The at-least-one-surface startup check becomes a nothing-to-serve
error: no mounts (or the registry disabled by flag) and the resolver
disabled. The mount graph is still built and validated on every tier,
and a registry surface disabled by flag still skips strict upstream
credential resolution.

Closes #12767
Copilot AI review requested due to automatic review settings July 2, 2026 21:32
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

pnpr: derive registry surface from mounts and always mount account routes

✨ Enhancement ⚙️ Configuration changes 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Derive npm-registry surface enablement from mounts: and --disable-registry.
• Reject removed registry: config key to avoid fail-open exposure.
• Always mount account/token endpoints so resolver-only tiers can authenticate.
Diagram

graph TD
  A["config.yaml (docs)"] --> B["Config loader (config.rs)"] --> C["Feature flags"] --> D["Axum router (server.rs)"] --> E["Registry routes"]
  D --> F["Resolver routes"]
  D --> G["Account routes"]
  H["Tests"] --> B --> D
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Soft-deprecate `registry:` (warn + ignore) instead of erroring
  • ➕ Smoother upgrades for existing configs that still carry registry:
  • ➕ Avoids startup failures when operators forget to remove the key
  • ➖ Risk of silent semantic drift (especially registry: { enabled: false })
  • ➖ Harder to guarantee fail-closed behavior for security-sensitive toggles
2. Keep `registry.enabled` but enforce consistency with `mounts:`
  • ➕ Preserves an explicit operator toggle for registry exposure
  • ➕ Can emit a targeted error when mounts exist but registry.enabled=false
  • ➖ Continues duplicating config intent across two fields
  • ➖ Still requires defining precedence rules and documenting them
3. Gate account endpoints behind resolver/registry enablement
  • ➕ Smaller always-exposed API surface area
  • ➕ Keeps all auth-adjacent routes grouped under a surface conceptually
  • ➖ Breaks resolver-only tiers that need to mint/manage tokens
  • ➖ Encourages coupling auth workflows to registry-serving replicas

Recommendation: The PR’s approach is the best trade-off: deriving registry enablement from mounts: eliminates contradictory configuration, and rejecting the removed registry: key avoids a fail-open scenario for previously disabling configs. Always-mounting account/token endpoints is a pragmatic separation of concerns that enables resolver-only tiers to be operationally independent while keeping registry routes truly absent when disabled.

Files changed (6) +343 / -209

Enhancement (2) +237 / -135
config.rsDerive registry enablement from mounts and reject removed 'registry:' key +58/-30

Derive registry enablement from mounts and reject removed 'registry:' key

• Replaces the YAML-configured registry toggle with derived enablement ('!mounts.is_empty()'), still honoring '--disable-registry'. Adds a custom deserializer that errors on any 'registry:' key presence with migration guidance, and updates the startup invariant error to a clearer "nothing to serve" message.

pnpr/crates/pnpr/src/config.rs

server.rsAlways mount account/token endpoints and decouple them from registry surface +179/-105

Always mount account/token endpoints and decouple them from registry surface

• Moves login/whoami/profile/token list+revoke/logout onto dedicated always-mounted routes (also under '/{prefix}/...' constrained to '~'-prefixed paths). Removes legacy handling of these endpoints from the registry catch-all segment handlers and updates startup/auth loading commentary to match the new routing model.

pnpr/crates/pnpr/src/server.rs

Tests (2) +98 / -67
tests.rsUpdate config unit tests for derived registry surface and key rejection +45/-67

Update config unit tests for derived registry surface and key rejection

• Reworks tests to assert registry enablement is determined by mount presence and that resolver toggles still parse as before. Adds coverage ensuring any 'registry:' key form fails config load, and updates the misconfiguration test to the new "nothing to serve" invariant.

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

server.rsExtend resolver-only integration test to cover account endpoints and token use +53/-0

Extend resolver-only integration test to cover account endpoints and token use

• Enhances the resolver-only scenario to allow user creation/login and to validate whoami/profile/token-list endpoints work without registry routes. Verifies a minted token successfully authenticates to the resolver endpoint, ensuring resolver-only tiers can manage credentials independently.

pnpr/crates/pnpr/tests/server.rs

Other (2) +8 / -7
config.yamlUpdate bundled config docs to explain derived registry surface +6/-5

Update bundled config docs to explain derived registry surface

• Removes the documented 'registry:' toggle and explains that the registry surface is enabled when 'mounts:' are declared, with '--disable-registry' as a per-tier override. Clarifies that account endpoints are always served alongside '/-/ping'.

pnpr/crates/pnpr/config.yaml

main.rsClarify '--disable-registry' CLI semantics under derived enablement +2/-2

Clarify '--disable-registry' CLI semantics under derived enablement

• Updates the flag help text to reflect that registry routes are served whenever mounts exist, unless explicitly disabled per tier via '--disable-registry'.

pnpr/crates/pnpr/src/main.rs

@coderabbitai

coderabbitai Bot commented Jul 2, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a73499b-f840-4456-a178-2af3502b5500

📥 Commits

Reviewing files that changed from the base of the PR and between 9efb050 and 25ff5e1.

📒 Files selected for processing (1)
  • pnpr/crates/pnpr/src/server.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • pnpr/crates/pnpr/src/server.rs

📝 Walkthrough

Walkthrough

This PR derives registry-surface enablement from mounts, moves account endpoints to dedicated always-on routes, removes account handling from generic dispatch, and updates related config, CLI, logging, and tests.

Changes

Registry surface derivation and account routing

Layer / File(s) Summary
Config derivation and docs
pnpr/crates/pnpr/config.yaml, pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/src/main.rs
Registry enablement is derived from mounts and override flags, and the related comments and help text describe the updated surface behavior.
Config validation tests
pnpr/crates/pnpr/src/config/tests.rs
Tests cover mounts-derived registry enablement, resolver parsing defaults, resolver typo handling, resolver disablement with mounts, and the no-mounts no-resolver error case.
Account route registration
pnpr/crates/pnpr/src/server.rs
Account endpoints are registered as dedicated always-mounted routes with tilde-prefix aliases, request bodies for login/adduser are capped, and startup comments and access-log URI handling are updated.
Account endpoint tests
pnpr/crates/pnpr/tests/auth_user_endpoints.rs, pnpr/crates/pnpr/tests/server.rs, pnpr/crates/pnpr/src/server/tests.rs
The tests cover oversized login rejection, authenticated account/token access from the resolver-only path, and redaction of logout tokens in access-log URIs.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConfigFile
  participant ConfigLoader
  participant Router
  participant AccountRoutes

  ConfigFile->>ConfigLoader: parse mounts and resolver settings
  ConfigLoader->>Router: build tier routes from derived enablement
  Router->>AccountRoutes: mount whoami/login/logout/profile/token routes
  AccountRoutes-->>Router: always-on account responses
Loading

Assessment against linked issues

Objective Addressed Explanation
Remove top-level registry: config field; derive registry surface from mounts: [#12767]
Keep resolver: key as-is [#12767]
Move account/token endpoints out of the registry surface [#12767]
Keep per-tier disable flags and update the "nothing to serve" check [#12767]

Possibly related PRs

  • pnpm/pnpm#12563: Changes pnpr surface enablement behavior in config and server code.
  • pnpm/pnpm#12574: Touches the same auth/account route handling in server.rs.
  • pnpm/pnpm#12747: Shares the mount-derived routing foundation used by this change.

Suggested labels: product: pnpr

Suggested reviewers: juanpicado

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-12767

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.

pnpr is pre-1.0 and experimental with no deployed configs to protect,
so the removed `registry:` key needs no migration error - it is now
just another unknown key the verdaccio-lenient parser ignores.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (2) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. ensure_a_feature_is_enabled() treats --disable-registry as “nothing to serve” 📎 Requirement gap ≡ Correctness
Description
The startup guard now errors when mounts exist but --disable-registry is set and the resolver is
disabled, because it checks effective registry.enabled rather than the mounts-derived registry
capability. This violates the compliance requirement that “nothing to serve” should only be
triggered when no mounts are declared and the resolver is disabled.
Code

pnpr/crates/pnpr/src/config.rs[R1551-1565]

+    /// At least one top-level surface must be served; a server with no
+    /// registry surface (no mounts declared, or `--disable-registry`) and
+    /// the resolver disabled would answer only `/-/ping` and the account
+    /// endpoints. Checked at config load and again in the serve/router
+    /// entry points for programmatically built configs.
  pub fn ensure_a_feature_is_enabled(&self) -> Result<(), RegistryError> {
      if self.registry.enabled || self.resolver.enabled {
          Ok(())
      } else {
          Err(RegistryError::InvalidConfig {
-                reason: "at least one of `registry` or `resolver` must be enabled".to_string(),
+                reason: "nothing to serve: the npm-registry surface is off (no `mounts:` \
+                         declared, or `--disable-registry`) and the resolver is disabled"
+                    .to_string(),
          })
      }
Evidence
PR Compliance ID 5 requires that the startup error only occurs when there are no mounts declared and
the resolver is disabled. The current implementation uses self.registry.enabled (which is false
when --disable-registry is set even if mounts exist), so it can incorrectly throw the “nothing to
serve” error in a mounts-present configuration.

Update “nothing to serve” startup check to reflect mounts-derived registry surface
pnpr/crates/pnpr/src/config.rs[1551-1565]

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 “nothing to serve” startup check currently fails when `--disable-registry` disables the registry surface even if mounts exist, because it checks `self.registry.enabled || self.resolver.enabled`.
## Issue Context
Per PR Compliance ID 5, startup should only error when **no mounts are declared** AND the resolver is disabled (via config and/or CLI). With mounts present, the config is valid even if the registry is effectively disabled by CLI.
## Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1502-1565]

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


2. Bearer token logged in URI ✓ Resolved 🐞 Bug ⛨ Security
Description
The newly always-mounted logout route DELETE /-/user/token/{token} places the raw bearer token in
the URL path, while the global access TraceLayer logs request.uri() verbatim; this will write
reusable credentials into logs on resolver-only tiers too. Any log reader (or downstream log export)
can replay leaked tokens until revoked.
Code

pnpr/crates/pnpr/src/server.rs[R298-305]

+        .route("/-/user/token/{token}", delete(delete_session_token))
+        .route("/{prefix}/-/user/token/{token}", delete(delete_session_token_prefixed))
+        .route("/-/npm/v1/user", get(get_profile))
+        .route("/{prefix}/-/npm/v1/user", get(get_profile_prefixed))
+        .route("/-/npm/v1/tokens", get(get_token_list))
+        .route("/{prefix}/-/npm/v1/tokens", get(get_token_list_prefixed))
+        .route("/-/npm/v1/tokens/token/{key}", delete(delete_token_by_key))
+        .route("/{prefix}/-/npm/v1/tokens/token/{key}", delete(delete_token_by_key_prefixed));
Evidence
The router now mounts a logout endpoint whose URL contains the raw bearer token, and the access
logger records the full URI; therefore the token will be present in logs for these requests.

pnpr/crates/pnpr/src/server.rs[273-305]
pnpr/crates/pnpr/src/server.rs[409-417]
pnpr/crates/pnpr/src/server.rs[2190-2204]

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 access log span includes `uri = %request.uri()`, which will include sensitive path segments for endpoints like `DELETE /-/user/token/{token}` where `{token}` is the raw bearer token. With account endpoints now mounted on every tier, this becomes a practical credential-leak vector for resolver-only deployments.
### Issue Context
- The router now always mounts `/-/user/token/{token}` and its prefixed twin.
- The access TraceLayer logs the full request URI.
- The logout handler explicitly documents that the token is the raw bearer token in the URL.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[293-305]
- pnpr/crates/pnpr/src/server.rs[409-421]
- pnpr/crates/pnpr/src/server.rs[2190-2204]
### Suggested fix approach
1. Add a small helper to produce a redacted URI string for logging (fast path: return the original for most routes).
2. In `TraceLayer::make_span_with`, log the redacted URI instead of `request.uri()`.
- Redact at least:
- `/-/user/token/<...>` and `/<prefix>/-/user/token/<...>`
- (Optionally) `/-/npm/v1/tokens/token/<...>` and prefixed variant, if you consider token keys sensitive in your environment.
3. Consider also capping/validating the `{token}` path parameter length in the logout handler to reduce worst-case CPU (hashing/lookup) on intentionally huge path segments, but the primary fix is log redaction.

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


3. registry: key not rejected 📎 Requirement gap ⛨ Security
Description
The config loader remains verdaccio-lenient, so because ConfigFile no longer declares a registry
field and does not deny unknown YAML keys, a removed top-level registry: key will be silently
dropped instead of failing config load. This violates the requirement that registry: is no longer
accepted and can cause a fail-open where legacy configs like registry: { enabled: false } still
expose the registry surface whenever mounts: is non-empty (unless overridden by the CLI).
Code

pnpr/crates/pnpr/src/config.rs[R1012-1018]

+    /// pnpr-only feature toggle for the resolver surface. On unless
+    /// explicitly disabled; absent on a stock verdaccio config, so it
+    /// stays enabled there. `Option` so a bare `resolver:` (which YAML
+    /// parses as null) is accepted as "default" rather than failing to
+    /// deserialize into the struct.
#[serde(default)]
resolver: Option<FeatureFile>,
Evidence
Compliance requires that the top-level registry: key is no longer accepted by the schema, but
ConfigFile deserialization is intentionally lenient (no deny_unknown_fields) and the struct no
longer contains a registry field, which means any YAML registry: entry will be treated as an
unknown top-level key and silently ignored rather than producing a config error. Additionally, the
config load logic now derives registry.enabled from whether mounts is non-empty (and a CLI
disable override), so a legacy registry.enabled: false setting cannot actually disable the
registry surface and will be ignored, creating unexpected fail-open exposure when mounts exist.

Remove top-level registry: config field and derive registry surface from mounts:
pnpr/crates/pnpr/src/config.rs[985-1051]
pnpr/crates/pnpr/src/config.rs[1478-1484]
pnpr/crates/pnpr/src/config.rs[1471-1549]

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 PR removes the `registry:` field from `ConfigFile`, but because config deserialization is intentionally verdaccio-lenient about unknown top-level keys, a `registry:` key will now be silently ignored instead of rejected with migration guidance. This violates the compliance requirement that `registry:` is no longer accepted and, since `registry.enabled` is now derived from `mounts:` (plus a CLI override), legacy configs like `registry: { enabled: false }` can unintentionally leave the registry surface enabled whenever mounts are present.
## Issue Context
Compliance requires the YAML/Config schema to fail-closed when a top-level `registry:` key is present (with clear migration guidance). Today `ConfigFile` does not deny unknown fields for verdaccio compatibility, so removing the `registry` field causes `registry:` to be treated as unknown and dropped; separately, `registry.enabled` is computed solely from `!mounts.is_empty()` and `--disable-registry`, so legacy `registry.enabled: false` has no effect.
Suggested implementation approaches:
1. Preserve lenient parsing *but* explicitly detect a top-level `registry` key during config load.
- Option A (preferred): add a `#[serde(flatten)] extra: IndexMap<String, serde_saphyr::Value>` (or equivalent value type) to `ConfigFile`, and after deserialization error if `extra.contains_key("registry")`.
- Option B: parse once into a generic YAML value/map, check for `registry` presence, then deserialize into `ConfigFile`.
2. Return `RegistryError::InvalidConfig` (or equivalent) with clear migration guidance (e.g. “remove `registry:`; registry surface is now derived from `mounts:`; use `--disable-registry` per tier”).
3. Add tests covering at least `registry:` (null), `registry: { enabled: false }`, and `registry: false` (scalar), ensuring each fails config load with the guidance string.
## Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[985-1051]
- pnpr/crates/pnpr/src/config.rs[1471-1549]

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



Remediation recommended

4. Login token response cacheable 🐞 Bug ⛨ Security
Description
put_login/put_login_prefixed are now mounted on every tier and return a freshly minted bearer
token via add_user, but the response uses Cache-Control: no-cache, which does not forbid
intermediaries from storing the token-bearing 201 response. This increases the risk of token
retention/exposure in shared caching layers (proxies/CDNs) compared to an explicit no-store
policy.
Code

pnpr/crates/pnpr/src/server.rs[R1012-1020]

+async fn put_login(
+    State(state): State<AppState>,
+    Path(user): Path<String>,
+    body: axum::body::Bytes,
+) -> Response {
+    match user.strip_prefix("org.couchdb.user:") {
+        Some(name) => add_user(&state, name, &body).await,
+        None => not_found(),
+    }
Evidence
The router now mounts login on every tier and put_login forwards to add_user, which returns a
JSON body containing token while setting Cache-Control: no-cache (not no-store). The codebase
already defines private_no_cache for sensitive identity endpoints using private, no-store,
showing the intended caching posture for credentialed data.

pnpr/crates/pnpr/src/server.rs[280-318]
pnpr/crates/pnpr/src/server.rs[1010-1032]
pnpr/crates/pnpr/src/server.rs[2090-2135]
pnpr/crates/pnpr/src/server.rs[2339-2352]

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 login/adduser endpoint returns a bearer token but sets `Cache-Control: no-cache`, which does not explicitly prevent storage of the token-bearing response.
### Issue Context
This PR mounts the login/adduser route on every tier (`router_with_auth_and_osv`), so token issuance is reachable in more deployment topologies (including ones fronted by shared proxies).
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[300-310]
- pnpr/crates/pnpr/src/server.rs[2096-2135]
- pnpr/crates/pnpr/src/server.rs[2339-2352]
### What to change
- In `add_user`, change the response caching headers to explicitly forbid storage, e.g. `Cache-Control: private, no-store` (or at minimum `no-store`).
- Consider reusing `private_no_cache(...)` or adding a small helper for token-bearing responses (note `private_no_cache` also sets `Vary: Authorization`; for login it’s optional but harmless).
- Add/extend a test to assert the login response carries `no-store` (similar to existing auth-endpoint cache header tests).

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


5. Login bcrypt DoS surface 🐞 Bug ⛨ Security
Description
router_with_auth_and_osv now mounts the unauthenticated adduser/login endpoint on every tier
(including resolver-only), and each request can trigger expensive bcrypt hashing/verification via
add_userauth.users.add_or_login. This allows unauthenticated callers to consume significant
CPU with repeated login attempts, degrading availability of the tier’s resolver/registry surfaces.
Code

pnpr/crates/pnpr/src/server.rs[R286-310]

+    // The account endpoints — adduser/login, whoami, profile, token
+    // listing/revocation, logout — are pnpr account management, not
+    // npm-registry functionality: they mint and manage the tokens every
+    // authenticated surface demands, so they ride every tier alongside
+    // `/-/ping`. A resolver-only tier can then issue its own credentials
+    // (`pnpm login --registry https://<resolver-host>/`) instead of
+    // depending on a registry-serving replica that shares the auth backend.
+    //
+    // Each endpoint also answers under any `/~<prefix>/`, so a client whose
+    // registry URL is a mount endpoint can log in against it. The identity
+    // endpoints are global and consult no mount state; a mount-table lookup
+    // would gate nothing while turning the 401-vs-404 split into an
+    // existence oracle for private mount names that the content handlers
+    // carefully mask.
+    router = router
+        .route("/-/whoami", get(get_whoami))
+        .route("/{prefix}/-/whoami", get(get_whoami_prefixed))
+        .route(
+            "/-/user/{user}",
+            put(put_login).route_layer(DefaultBodyLimit::max(MAX_LOGIN_BODY_BYTES)),
+        )
+        .route(
+            "/{prefix}/-/user/{user}",
+            put(put_login_prefixed).route_layer(DefaultBodyLimit::max(MAX_LOGIN_BODY_BYTES)),
+        )
Evidence
The router now mounts login routes unconditionally on every tier, making them reachable even when
the registry surface is off. Those routes call add_user, which delegates to
auth.users.add_or_login; the auth implementation uses bcrypt hashing/verification, documented as
~50–100ms cost, making repeated unauthenticated login attempts a viable CPU exhaustion vector.

pnpr/crates/pnpr/src/server.rs[280-318]
pnpr/crates/pnpr/src/server.rs[2088-2134]
pnpr/crates/pnpr/src/auth.rs[761-795]

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 login/adduser endpoint is now always mounted (even on resolver-only tiers) and is reachable without prior authentication. Each request may invoke bcrypt hashing/verification, which is intentionally expensive; repeated requests can cause CPU exhaustion and deny service.
## Issue Context
The PR intentionally moved account endpoints (including login) out of the registry-gated surface so resolver-only tiers can mint/manage tokens. The existing 64KiB body cap mitigates memory amplification, but CPU amplification via bcrypt remains.
## Fix Focus Areas
Add an explicit throttle on the login route (or the underlying auth path) to bound bcrypt work under abuse.
Practical options:
- Apply a `ConcurrencyLimitLayer` (or similar) specifically to the two login routes (`/-/user/{user}` and `/{prefix}/-/user/{user}`), returning 429/503 when saturated.
- Or add a dedicated `tokio::sync::Semaphore` in shared state to cap concurrent login attempts, acquiring a permit at the start of `put_login` / `put_login_prefixed` and failing fast if unavailable.
- If you already have a preferred rate-limiter middleware in the repo, reuse it for per-IP or global rate limiting.
### References
- pnpr/crates/pnpr/src/server.rs[280-318]
- pnpr/crates/pnpr/src/server.rs[2088-2134]
- pnpr/crates/pnpr/src/auth.rs[761-795]

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


6. URI logging allocates always 🐞 Bug ➹ Performance
Description
loggable_uri() returns a newly allocated String and is called for every request when building
the access-log span, so even the common non-redaction case (None => uri.to_string()) adds
per-request heap allocation and formatting overhead. This is on pnpr’s hottest path (every HTTP
request), so it can measurably increase CPU/allocator pressure under high request rates (e.g.
install fan-out).
Code

pnpr/crates/pnpr/src/server.rs[R426-432]

                   target: "pnpr::access",
                   "request",
                   method = %request.method(),
-                        uri = %request.uri(),
+                        uri = %loggable_uri(request.uri()),
                   // Filled in by `record_cache_status` for packument
                   // reads (e.g. `cache=hit`); stays absent otherwise.
                   cache = tracing::field::Empty,
Evidence
The access TraceLayer calls loggable_uri(request.uri()) while creating a span for each request,
and loggable_uri() allocates a String even when no token marker is present via
uri.to_string(). This makes the allocation unconditional on the request hot path.

pnpr/crates/pnpr/src/server.rs[422-463]

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 access log span currently uses `uri = %loggable_uri(request.uri())`, and `loggable_uri()` returns a `String` (`uri.to_string()` in the common case). This introduces an unconditional heap allocation for *every* HTTP request.
## Issue Context
The redaction goal is correct (never log the raw bearer token in `/-/user/token/{token}`), but the implementation can preserve that security property without allocating in the non-redaction case.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[423-463]
## Suggested fix approach
- Replace `fn loggable_uri(...) -> String` with a small wrapper type implementing `std::fmt::Display`, e.g. `struct LoggableUri<'a>(&'a Uri);`.
- In `Display::fmt`, scan `self.0.path()` for `"/-/user/token/"`:
- If found, write the prefix and `"<redacted>"` directly to the formatter.
- If not found, write `path_and_query` (or the `Uri` itself) directly, avoiding `to_string()`.
- Change the span field to `uri = %LoggableUri(request.uri())` so `tracing` can format without allocating.

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


View more (1)
7. Login body allows 100MiB ✓ Resolved 🐞 Bug ⛨ Security
Description
Because account endpoints are now mounted on every tier, unauthenticated clients can send very large
request bodies to the login/adduser endpoint and force the server to buffer and JSON-parse them,
enabling resource exhaustion under repeated/concurrent requests on resolver-only deployments. This
is amplified by the global 100MiB DefaultBodyLimit that applies to all routes, even though only
publish needs that ceiling.
Code

pnpr/crates/pnpr/src/server.rs[R279-305]

+    // The account endpoints — adduser/login, whoami, profile, token
+    // listing/revocation, logout — are pnpr account management, not
+    // npm-registry functionality: they mint and manage the tokens every
+    // authenticated surface demands, so they ride every tier alongside
+    // `/-/ping`. A resolver-only tier can then issue its own credentials
+    // (`pnpm login --registry https://<resolver-host>/`) instead of
+    // depending on a registry-serving replica that shares the auth backend.
+    //
+    // Each endpoint also answers under any `/~<prefix>/`, so a client whose
+    // registry URL is a mount endpoint can log in against it. The identity
+    // endpoints are global and consult no mount state; a mount-table lookup
+    // would gate nothing while turning the 401-vs-404 split into an
+    // existence oracle for private mount names that the content handlers
+    // carefully mask.
+    router = router
+        .route("/-/whoami", get(get_whoami))
+        .route("/{prefix}/-/whoami", get(get_whoami_prefixed))
+        .route("/-/user/{user}", put(put_login))
+        .route("/{prefix}/-/user/{user}", put(put_login_prefixed))
+        .route("/-/user/token/{token}", delete(delete_session_token))
+        .route("/{prefix}/-/user/token/{token}", delete(delete_session_token_prefixed))
+        .route("/-/npm/v1/user", get(get_profile))
+        .route("/{prefix}/-/npm/v1/user", get(get_profile_prefixed))
+        .route("/-/npm/v1/tokens", get(get_token_list))
+        .route("/{prefix}/-/npm/v1/tokens", get(get_token_list_prefixed))
+        .route("/-/npm/v1/tokens/token/{key}", delete(delete_token_by_key))
+        .route("/{prefix}/-/npm/v1/tokens/token/{key}", delete(delete_token_by_key_prefixed));
Evidence
The router now mounts login/token/account routes unconditionally, including PUT /-/user/{user}.
The same router applies a global 100MiB DefaultBodyLimit (via MAX_PUBLISH_BODY_BYTES) to all
routes, and the login handler accepts body: axum::body::Bytes, which buffers the full request body
before parsing.

pnpr/crates/pnpr/src/server.rs[261-305]
pnpr/crates/pnpr/src/server.rs[60-70]
pnpr/crates/pnpr/src/server.rs[374-381]
pnpr/crates/pnpr/src/server.rs[978-988]

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

## Issue description
Account/login endpoints are now always mounted, including on resolver-only tiers. The login handler buffers the request body into `axum::body::Bytes`, and the router applies a global 100MiB body limit intended for publish.
This means an unauthenticated attacker can repeatedly send near-100MiB PUT bodies to `/-/user/...` and force significant memory allocation + JSON parsing work, creating a resource-exhaustion vector on tiers that otherwise wouldn’t expose large-body endpoints.
### Issue Context
- Keep the 100MiB limit for publish endpoints.
- Add a much smaller per-route body limit for `PUT /-/user/{user}` and its prefixed twin, since they only need small JSON payloads.
- Implement via a per-route `RequestBodyLimitLayer` (or an equivalent mechanism that *overrides* the router-wide default).
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[261-305]
- pnpr/crates/pnpr/src/server.rs[374-381]
- pnpr/crates/pnpr/src/server.rs[978-1000]

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


Grey Divider

Qodo Logo

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

qodo-free-for-open-source-projects Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates pnpr’s configuration and routing so the npm-registry surface is derived from declared mounts: (and can still be disabled per-tier via --disable-registry), while moving account/token endpoints onto always-mounted routes so resolver-only tiers can still mint/manage credentials.

Changes:

  • Derive registry.enabled from mounts: presence and reject the removed top-level registry: config key with migration guidance.
  • Move account endpoints (login/adduser, whoami, profile, token list/revoke, logout) out of the registry-gated surface and mount them on every tier (including /~<prefix>/... variants).
  • Update tests and bundled config comments to reflect the derived-registry model and “nothing to serve” startup validation.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pnpr/crates/pnpr/tests/server.rs Extends resolver-only behavior testing to include account/token flows.
pnpr/crates/pnpr/src/server.rs Reworks router composition to always mount account routes and gates registry routes on derived enablement.
pnpr/crates/pnpr/src/main.rs Updates CLI flag help text for --disable-registry to match derived-registry behavior.
pnpr/crates/pnpr/src/config/tests.rs Updates config tests to reflect derived registry surface, removed key rejection, and “nothing to serve” checks.
pnpr/crates/pnpr/src/config.rs Implements derived registry enablement, rejects removed registry: key, and updates validation/error messaging.
pnpr/crates/pnpr/config.yaml Updates bundled config documentation to explain derived registry surface and always-served account endpoints.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pnpr/crates/pnpr/tests/server.rs Outdated
Comment thread pnpr/crates/pnpr/src/main.rs Outdated
Copilot AI review requested due to automatic review settings July 2, 2026 21:39
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 df63e1c

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

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 df63e1c

…lver tiers

Address PR review findings on #12773:

- The adduser/login endpoint is the one body-accepting route reachable
  anonymously on every tier; give it a 64 KiB body cap instead of the
  inherited 100 MiB publish ceiling so unauthenticated callers cannot
  use it as a buffer-and-parse amplifier.
- Exercise the /~<prefix>/ account-route twins in the resolver-only
  test, proving they do not depend on the absent registry segment
  routes.
- Fix the --disable-registry help text to say the surface needs at
  least one mount, not merely a mounts: key.
@codecov-commenter

codecov-commenter commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.85294% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.57%. Comparing base (991405e) to head (25ff5e1).

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/server.rs 94.65% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12773      +/-   ##
==========================================
+ Coverage   85.55%   85.57%   +0.01%     
==========================================
  Files         413      413              
  Lines       63908    63986      +78     
==========================================
+ Hits        54679    54754      +75     
- Misses       9229     9232       +3     

☔ 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.

@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.

🧹 Nitpick comments (1)
pnpr/crates/pnpr/tests/auth_user_endpoints.rs (1)

164-164: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider referencing the shared body-limit constant instead of a literal.

The 65 * 1024 literal isn't tied to MAX_LOGIN_BODY_BYTES (the constant enforcing this limit in server.rs). If that constant changes, this test's magic number could silently drift out of sync with the actual limit.

♻️ Suggested tie-in to the shared constant
-    let body = adduser_body("alice", &"x".repeat(65 * 1024));
+    let body = adduser_body("alice", &"x".repeat(MAX_LOGIN_BODY_BYTES as usize + 1024));
🤖 Prompt for 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.

In `@pnpr/crates/pnpr/tests/auth_user_endpoints.rs` at line 164, The test uses a
hardcoded 65 * 1024 body size, which can drift from the real request limit;
update the auth_user_endpoints test to derive the oversized payload from
MAX_LOGIN_BODY_BYTES instead of a magic number. Keep the existing adduser_body
call, but size the string relative to the shared constant so the test stays
aligned with the server-side limit enforcement in MAX_LOGIN_BODY_BYTES.
🤖 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.

Nitpick comments:
In `@pnpr/crates/pnpr/tests/auth_user_endpoints.rs`:
- Line 164: The test uses a hardcoded 65 * 1024 body size, which can drift from
the real request limit; update the auth_user_endpoints test to derive the
oversized payload from MAX_LOGIN_BODY_BYTES instead of a magic number. Keep the
existing adduser_body call, but size the string relative to the shared constant
so the test stays aligned with the server-side limit enforcement in
MAX_LOGIN_BODY_BYTES.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 00de2e19-a8d2-4cb5-9510-c42a2a5cada3

📥 Commits

Reviewing files that changed from the base of the PR and between df63e1c and ac932a0.

📒 Files selected for processing (4)
  • pnpr/crates/pnpr/src/main.rs
  • pnpr/crates/pnpr/src/server.rs
  • pnpr/crates/pnpr/tests/auth_user_endpoints.rs
  • pnpr/crates/pnpr/tests/server.rs
✅ Files skipped from review due to trivial changes (1)
  • pnpr/crates/pnpr/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • pnpr/crates/pnpr/tests/server.rs
  • 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 ac932a0

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

qodo-free-for-open-source-projects Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

npm's logout protocol (DELETE .../-/user/token/{token}) carries the raw
bearer token in the URL path, and the access TraceLayer logged the
request URI verbatim - writing a reusable credential into every log
line the logout produced. With the account endpoints now mounted on
every tier (#12773 review finding), redact everything after
the /-/user/token/ marker in the logged URI; all other URIs are logged
unchanged.
Copilot AI review requested due to automatic review settings July 2, 2026 21:58
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9efb050

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

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 9efb050

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 25ff5e1

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 25ff5e1

@zkochan zkochan merged commit 9ef4c01 into main Jul 2, 2026
32 checks passed
@zkochan zkochan deleted the issue-12767 branch July 2, 2026 22:34
zkochan added a commit to pnpm/pnpm.io that referenced this pull request Jul 3, 2026
…maps (#834)

Documents the redesigned model from pnpm/pnpm#12773, pnpm/pnpm#12778,
and pnpm/pnpm#12787:

- mounts:/defaultTarget: -> registries:/defaultRegistry:
- routers collapse to ordered sources: lists; a package resolves to the
  first listed source whose declared packages claim it
- the namespace and ACL merge into per-registry packages: maps, with
  most-specific-key selection and namespace enforcement on every path
- the registry: config toggle is gone; the npm-registry surface is
  served iff at least one registry is declared, minus --disable-registry
- the account (login/token) endpoints are always served on every tier
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pnpr: remove the top-level registry: config field — derive the registry surface from declared mounts

3 participants