Skip to content

feat(pnpr): declare patterns on registries, reduce routers to ordered sources#12778

Merged
zkochan merged 12 commits into
mainfrom
improve-pnpr-api
Jul 3, 2026
Merged

feat(pnpr): declare patterns on registries, reduce routers to ordered sources#12778
zkochan merged 12 commits into
mainfrom
improve-pnpr-api

Conversation

@zkochan

@zkochan zkochan commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

Implements the revised model from pnpm/rfcs#16 in pnpr, replacing the route-level-pattern shape (landed by #12747) outright — pnpr is pre-1.0, so there is no compatibility mode and no migration for the old keys.

  • Mounts are renamed to registries. Per the RFC's updated Rationale, the unit is simply a named registry: the YAML surface is now registries: / defaultRegistry: (was mounts: / defaultTarget:), and the Rust surface follows the RFC's sketch (MountKindRegistry, MountsRegistries, MountConfigErrorRegistryConfigError, the mount module → registry, Config.mountsConfig.registries, "name a hosted registry" errors, /~<name>/ in docs). The axum-level "routes are mounted" wording stays — that's framework vocabulary, not the renamed concept.
  • Patterns move onto the concrete registries. Hosted and upstream registries take an optional patterns: list (same four-shape decidable language: **, @*/*, @scope/*, exact name; omitted = every name). This is the registry's declared namespace — its routing claim and its request filter in one declaration.
  • Routers collapse to ordered sources: lists. A package resolves to the first listed source whose patterns claim it, authoritatively. A pattern-less source claims every name (the catch-all) and must be listed last. A router can order competing claims but can never assign a name to a registry that doesn't claim it.
  • The namespace is enforced at the registry, on every path to it. An off-pattern read is a definitive 404 answered before storage or the upstream is consulted; an off-pattern publish is rejected with a clear 400 naming the reason — through a router, via the path-less base, and at the registry's own /~<name>/ URL alike. This closes the open hosted namespace (no dormant stored state that a later config edit or direct address would surface as authoritative, and a typo'd scope fails loudly at publish time) and stops an authorized caller from pulling arbitrary public names through a private upstream's server-owned credential.
  • Validation uses the same covers() machinery: unreachable sources (all claims covered by earlier sources' union, including a non-last pattern-less source), per-pattern shadowing across sources (identical claims by two sources are rejected, so namespaces that overlap in both directions fail in either order — genuinely ambiguous provenance), duplicate sources per router, duplicate patterns per registry, plus the carried-over unknown-source / self-reference / router-as-source / empty-router checks. A registry's own internally redundant patterns are allowed and don't count as self-shadowing.

Implementation choice: patterns live only in the registry graph (Config::registries), not duplicated into the hosted/uplink tables — enforcement happens in Registries::resolve, which every read, write, search, and cache-header decision already flows through, so the namespace really is one declaration. The bundled config.yaml moves the registry-mock fixture list onto the local registry (main becomes sources: [local, npmjs]), and Config::proxy / Config::static_serve build the equivalent graphs programmatically.

Tests: the registry unit suite is reworked for the new shapes and validation matrix; config tests cover registry-level patterns: parsing, duplicate/invalid pattern rejection, the registries: / defaultRegistry: keys, and the misordered-sources: startup failure; new server e2e tests pin off-pattern publish rejection and read 404s on every path (with an expect(0) mock proving a bounded upstream is never contacted for an unclaimed name), plus the existing no-fall-through, source-down-is-5xx, masking, and cache-header suites. 575 pnpr tests pass, and the pacquet e2e tests that drive a live pnpr server (pnpr_install) pass. A review round hardened two edges: server construction now folds every programmatic uplink into the registry graph and validates it (the per-request uplink fallback in dispatch is gone, so Registries::resolve is the only dispatch table), and the off-pattern publish rejection is gated on registry visibility so a denied caller gets the same 404 mask as on reads instead of a private-registry-revealing 400. Further rounds validate programmatic configs exactly like YAML loads (URL-safe names, path-safe and collision-free hosted orgs, scope patterns no package name could match, and concrete graph entries without backing serving config all fail startup), gate writes to private upstreams on their access: list (the read path's 403) before any routing rejection, and rename the remaining verdaccio-era "uplink" vocabulary to "upstream" (UpstreamConfig, Config.upstreams, the ~upstreams/ private cache namespace) — "uplink" survives only where compatibility demands it (the _uplinks verdaccio storage field and the legacy uplinks: YAML the benchmark emits for pre-registries binaries).

Squash Commit Body

Implements the revised model from pnpm/rfcs#16, replacing the
route-level-pattern shape outright (pre-1.0, no compatibility mode).

The config surface is renamed per the RFC: `mounts:` -> `registries:`,
`defaultTarget:` -> `defaultRegistry:`, and the Rust types follow
(`MountKind` -> `Registry`, `Mounts` -> `Registries`, the `mount` module
-> `registry`). The vocabulary is now: registry — a named surface pnpr
serves; origin — the external URL an upstream registry fetches from.

Hosted and upstream registries take an optional `patterns:` list — their
declared namespace (omitted = every name) — and a router collapses to an
ordered `sources:` list: a package resolves to the first listed source
whose patterns claim it. The namespace is enforced at the registry, on
every path to it: an off-pattern read is a definitive 404 answered before
storage or the upstream is consulted, and an off-pattern publish is
rejected with a clear reason — through a router and at the registry's own
`/~<name>/` URL alike. This closes the open hosted namespace (no dormant
stored state that a later source edit would surface as authoritative) and
stops an authorized caller from pulling arbitrary public names through a
private upstream's server-owned credential.

Validation translates to the same `covers()` machinery: unreachable
sources (all claims covered by earlier sources' union, including a
non-last pattern-less source), per-pattern shadowing across sources
(identical claims by two sources rejected, so bidirectionally-overlapping
namespaces fail in either order), duplicate sources per router, duplicate
patterns per registry, plus the carried-over unknown/self-referential/
router-as-source/empty-router checks. A registry's own internally
redundant patterns are allowed and do not count as self-shadowing.

Patterns live only in the registry graph (`Config::registries`), not
duplicated into the hosted/uplink tables: enforcement happens in
`Registries::resolve`, which every read, write, search, and cache-header
decision flows through, so the namespace is one declaration. The bundled
config.yaml moves the registry-mock fixture list onto the `local`
registry, and `Config::proxy` / `Config::static_serve` build the
equivalent graphs programmatically.

Checklist

  • pnpr-only change: the registry server has no TypeScript-CLI or pacquet/ counterpart to mirror (per pnpr/AGENTS.md), and per the RFC there are no lockfile or client-visible protocol changes.
  • Added or updated tests.
  • Updated the documentation if needed (bundled config.yaml comments and module docs; the design doc is the RFC itself, rfc(pnpr): registries declare their namespace; rename mounts to registries rfcs#16).

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

…rces

Implements the revised registry-mounts model from pnpm/rfcs#16, replacing
the route-level-pattern shape outright (pre-1.0, no `routes:` compatibility
mode).

Hosted and upstream mounts now take an optional `patterns:` list — their
declared namespace (omitted = every name) — and a router collapses to an
ordered `sources:` list: a package resolves to the first listed source
whose patterns claim it. The namespace is enforced at the mount, on every
path to it: an off-pattern read is a definitive 404 answered before storage
or the upstream is consulted, and an off-pattern publish is rejected with a
clear reason — through a router and at the mount's own `/~<mount>/` URL
alike. This closes the open hosted namespace (no dormant stored state that
a later source edit would surface as authoritative) and stops an authorized
caller from pulling arbitrary public names through a private upstream's
server-owned credential.

Validation translates to the same `covers()` machinery: unreachable
sources (all claims covered by earlier sources' union, including a
non-last pattern-less source), per-pattern shadowing across sources
(identical claims by two sources rejected, so bidirectionally-overlapping
namespaces fail in either order), duplicate sources per router, duplicate
patterns per mount, plus the carried-over unknown/self-referential/
router-as-source/empty-router checks. A mount's own internally redundant
patterns are allowed and do not count as self-shadowing.

Patterns live only in the mount graph (`Config::mounts`), not duplicated
into the hosted/uplink tables: enforcement happens in `Mounts::resolve`,
which every read, write, search, and cache-header decision flows through,
so the namespace is one declaration. The bundled config.yaml moves the
registry-mock fixture list onto the `local` mount, and `Config::proxy` /
`Config::static_serve` build the equivalent graphs programmatically.
Copilot AI review requested due to automatic review settings July 3, 2026 05:18
@coderabbitai

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

This PR replaces pnpr’s mount/router model with registry-based routing. Hosted and upstream registries now declare patterns, routers declare ordered sources, resolution can return Unclaimed, and config, server dispatch, tests, and docs are updated accordingly.

Changes

Registry routing refactor

Layer / File(s) Summary
Registry graph model
pnpr/crates/pnpr/src/registry.rs, pnpr/crates/pnpr/src/lib.rs
PackagePattern, Registry, Registries, and RegistryConfigError are added and exported for registry parsing, resolution, and closed-failure validation.
Mount module compatibility layer
pnpr/crates/pnpr/src/mount.rs, pnpr/crates/pnpr/src/mount/tests.rs
MountKind, Resolved, and Mounts are rewritten around declared patterns and ordered sources, and the mount tests are updated for the new helpers and validation cases.
Config schema and graph construction
pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/config.yaml, pnpr/crates/pnpr/src/config/tests.rs, pnpr/crates/pnpr/tests/auth_publish.rs, pnpr/crates/pnpr/tests/policy.rs
The YAML schema, config builder, and config tests switch to registries, patterns, and sources, with matching validation and fixture updates.
Server dispatch and HTTP surfaces
pnpr/crates/pnpr/src/server.rs, pnpr/crates/pnpr/tests/server.rs
Request routing, publish/write handling, search, cache gating, and server integration tests resolve through registry sources instead of mounts.
Terminology updates
pnpr/crates/pnpr/src/journal.rs, pnpr/crates/pnpr/src/main.rs, pnpr/crates/pnpr/src/s3.rs, pnpr/crates/pnpr/src/search.rs, pnpr/crates/pnpr/src/storage.rs, pnpr/crates/pnpr/src/upstream.rs
Doc comments and CLI help text are rewritten from mount terminology to registry terminology.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • pnpm/pnpm#12563: Both PRs touch pnpr/crates/pnpr/src/server.rs’s router construction to control dispatch boundaries; this PR switches to registry-graph routing and the retrieved PR adds resolver/registry feature toggles.
  • pnpm/pnpm#12593: Both PRs modify pnpr’s tarball-serving path in server.rs; this PR rewires tarball dispatch from the mount graph to the registry graph.
  • pnpm/pnpm#12747: This PR continues the same routing-model migration by replacing the mount-based graph, server handlers, and validation introduced in the retrieved PR with registry-based equivalents.

Suggested labels: product: pnpr

Suggested reviewers: juanpicado

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

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.

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

Copy link
Copy Markdown

PR Summary by Qodo

Move package patterns to mounts; simplify routers to ordered sources

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

Grey Divider

AI Description

• Move package-name pattern declarations from router routes onto concrete mounts.
• Collapse routers into ordered sources: lists with deterministic first-claim resolution.
• Enforce mount namespaces for reads (404) and publishes (400) across all URL paths.
Diagram

graph TD
  cfg["config.yaml"] --> loader["Config load"] --> mounts["Mounts graph"] --> resolve["Mounts::resolve (enforce patterns)"]
  resolve -->|"Concrete: hosted"| hosted[("Hosted store")]
  resolve -->|"Concrete: upstream"| up{{"Upstream registry"}}
  resolve -->|"Unclaimed"| reject["404 (read) / 400 (publish)"]
  subgraph Legend
    direction LR
    _file["File"] ~~~ _mod["Module"] ~~~ _db[("Database")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep route-level patterns on routers (status quo)
  • ➕ Smaller in-memory representation (router owns the matching rules)
  • ➕ Potentially simpler mental model if mounts are always used via routers
  • ➖ Cannot enforce namespace when mounts are addressed directly (/~/)
  • ➖ Allows “dormant” hosted state that could become authoritative after config edits
  • ➖ Harder to bound private upstreams against off-scope access via direct mount URLs
2. Duplicate patterns into hosted/uplink tables as well as MountKind
  • ➕ May simplify some call sites by reading patterns from serving config directly
  • ➕ Could reduce indirection if mounts graph and serving tables drift in responsibilities
  • ➖ Creates a consistency problem (two sources of truth for namespace)
  • ➖ Increases validation surface and risk of divergence during reloads/overrides
3. Enforce patterns only on publish, not on read resolution
  • ➕ Less disruptive change to read paths
  • ➕ Smaller behavioral change for existing installations
  • ➖ Does not prevent unauthorized reads routed via bounded upstream credentials
  • ➖ Still permits serving stored packages that should be out-of-namespace after config changes

Recommendation: Proceed with the PR’s approach: patterns as a single source of truth on concrete mounts, with routers as ordered sources: and enforcement centralized in Mounts::resolve. This yields uniform behavior across direct mount URLs, router URLs, and the path-less base while keeping validation decidable and preventing accidental cross-origin access.

Files changed (10) +790 / -606

Enhancement (3) +330 / -261
config.rsParse mount-level 'patterns:' and build routers from ordered sources +62/-74

Parse mount-level 'patterns:' and build routers from ordered sources

• Extends YAML shapes for hosted/upstream mounts with 'patterns' and changes router shape to 'sources'. Updates programmatic config builders ('proxy', 'static_serve') and mount graph construction to populate 'MountKind::{Hosted,Upstream}{patterns}' and validate patterns during mount compilation.

pnpr/crates/pnpr/src/config.rs

mount.rsMount namespaces via patterns; routers select first claiming source +213/-145

Mount namespaces via patterns; routers select first claiming source

• Moves patterns onto 'MountKind::{Hosted,Upstream}' and changes routers to ordered 'sources'. Adds 'Resolved::Unclaimed' and enforces namespaces in 'Mounts::resolve' for both direct mounts and routers; updates validation to detect duplicate sources, duplicate mount patterns, unreachable sources, and cross-source pattern shadowing.

pnpr/crates/pnpr/src/mount.rs

server.rsTreat unclaimed packages as definitive 404; reject off-pattern publishes +55/-42

Treat unclaimed packages as definitive 404; reject off-pattern publishes

• Introduces 'MountSource::Unclaimed' and maps 'Resolved::Unclaimed' into read 404 behavior before consulting hosted storage or upstreams. Extends publish target resolution to reject unclaimed names with a clear 400 reason, and simplifies search source selection to router 'sources' ordering.

pnpr/crates/pnpr/src/server.rs

Refactor (1) +1 / -3
lib.rsStop exporting removed Route type +1/-3

Stop exporting removed Route type

• Removes 'Route' from public re-exports to match the new router representation ('sources') and mount-level patterns model.

pnpr/crates/pnpr/src/lib.rs

Tests (5) +371 / -255
tests.rsUpdate config parsing tests for mount patterns and router sources +40/-20

Update config parsing tests for mount patterns and router sources

• Reworks YAML fixtures to use mount-level 'patterns' plus 'router.sources'. Adds coverage for invalid mount patterns and duplicate mount pattern rejection, and updates the misordered-router test to reflect pattern-less catch-all ordering semantics.

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

tests.rsRework mount resolution/validation tests for mount-level namespaces +167/-161

Rework mount resolution/validation tests for mount-level namespaces

• Updates test helpers and cases to reflect 'patterns' on concrete mounts and router 'sources'. Adds tests for direct-mount enforcement (Unclaimed), identical-claim rejection across sources, bidirectional overlap rejection, allowed intra-mount overlap, duplicate mount pattern rejection, and duplicate source rejection.

pnpr/crates/pnpr/src/mount/tests.rs

auth_publish.rsUpdate auth publish test config to router 'sources:' +1/-1

Update auth publish test config to router 'sources:'

• Adjusts the static test config snippet to define a router via 'sources: [local]' instead of route blocks.

pnpr/crates/pnpr/tests/auth_publish.rs

policy.rsUpdate policy test config to router 'sources:' +1/-1

Update policy test config to router 'sources:'

• Rewrites the embedded YAML mounts block to use 'sources: [local]' for router mounts.

pnpr/crates/pnpr/tests/policy.rs

server.rsUpdate server e2e coverage for mount namespaces and ordered sources +162/-72

Update server e2e coverage for mount namespaces and ordered sources

• Migrates router configuration builders to mount-level patterns + ordered sources. Adds e2e assertions that off-pattern reads 404 without hitting upstream (mock expect(0)) and that hosted mount patterns are enforced consistently on direct mount URLs, via routers, and via the path-less base, including publish rejection semantics.

pnpr/crates/pnpr/tests/server.rs

Other (1) +88 / -87
config.yamlMove fixture package patterns onto 'local' mount; routers use 'sources:' +88/-87

Move fixture package patterns onto 'local' mount; routers use 'sources:'

• Updates the bundled example config to declare the registry-mock fixture namespace via 'local.patterns' and collapses the 'main' router to 'sources: [local, npmjs]'. Adds explanatory comments about namespace enforcement and the requirement that pattern-less catch-alls appear last.

pnpr/crates/pnpr/config.yaml

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

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

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (8) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Disable-registry skips upstream auth 🐞 Bug ☼ Reliability ⭐ New
Description
Config::from_yaml_str_with_overrides passes registry.enabled into build_registries as
resolve_upstreams, so --disable-registry leaves Config.upstreams empty even when the resolver is
enabled. The resolver’s RouteHook ignores client-forwarded auth headers and derives server-managed
credentials from Config.upstreams, so private/proxied resolutions will fail (and won’t be
routable/cached correctly).
Code

pnpr/crates/pnpr/src/config.rs[R1520-1526]

+        let mut upstreams: IndexMap<String, UpstreamConfig> = IndexMap::new();
+        let (hosted, registries) = build_registries(
+            &mut upstreams,
+            file.registries,
+            file.default_registry,
+            registry.enabled,
+        )?;
Evidence
The diff shows upstream resolution is gated by registry.enabled, but the resolver always builds a
RouteContext and installs a RouteHook that selects server-managed Authorization headers from
config.upstreams and causes pacquet to ignore client-provided credentials when the hook is
present; therefore an empty config.upstreams under --disable-registry breaks proxied/private
resolution.

pnpr/crates/pnpr/src/config.rs[1502-1526]
pnpr/crates/pnpr/src/config.rs[1724-1781]
pnpr/crates/pnpr/src/route.rs[276-304]
pnpr/crates/pnpr/src/route.rs[596-616]
pnpr/crates/pnpr/src/resolver.rs[188-207]
pacquet/crates/network/src/auth.rs[38-43]

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

### Issue description
`--disable-registry` is intended to disable serving the npm-registry HTTP surface, but it currently also prevents upstream registry credentials/config from being resolved into `Config.upstreams`. The resolver still installs a route hook that ignores client-forwarded auth and relies on `Config.upstreams` to select server-managed credentials for proxied routes, so private dependency resolution breaks.

### Issue Context
- `Config::from_yaml_str_with_overrides` uses `registry.enabled` to decide whether to resolve upstream registries into `Config.upstreams`.
- `RouteContext::from_config` builds its proxied-alias table from `config.upstreams`.
- The resolver attaches a route hook (`RouteHook`) to `AuthHeaders`, and pacquet’s `AuthHeaders` contract is: when a route hook is present, client credentials are ignored.

### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1502-1526]
- pnpr/crates/pnpr/src/config.rs[1724-1791]

### Proposed fix
- Decouple “serve registry routes” from “resolve upstream configs needed by the resolver’s route hook”.
- Pass `resolve_upstreams = registry.enabled || resolver.enabled` (or an equivalent condition) into `build_registries`, so upstream credentials are resolved whenever the resolver is enabled.
- Optionally update related comments/docs so they don’t claim a resolver-only tier ‘never uses’ upstream secrets, since the route hook can require them.
- Consider updating `ensure_valid_registry_graph` invariants if they currently only require upstream backing config when `registry.enabled` is true, but resolver behavior requires them too.

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


2. Pnpr /~ cache-scope leak 🐞 Bug ⛨ Security
Description
RouteContext::classify assumes pnpr’s /~/ URLs are upstream endpoints and returns
RouteClass::Public when ` is not an authorized upstream alias, but /~/` now also serves hosted
registries and routers. That misclassifies private registry endpoints as public, allowing private
packuments to be stored/read from the shared public metadata cache keyspace and then served from
cache without re-checking the registry’s access/masking behavior.
Code

pnpr/crates/pnpr/src/route.rs[R330-347]

+            // A fetch to pnpr's own `/~<name>/` endpoint addresses that
+            // upstream, not a hosted package (a package name can never begin
+            // with `~`). Authorized callers resolve through the upstream;
+            // everyone else — and an unknown upstream — gets an anonymous
       // fetch the endpoint itself rejects, rather than falling through
       // to the hosted-package policy.
       if let Some(rest) = fetch.strip_prefix(hosted)
-                && let Some(uplink) = rest.strip_prefix('~').and_then(|rest| rest.split('/').next())
-                && !uplink.is_empty()
+                && let Some(upstream) =
+                    rest.strip_prefix('~').and_then(|rest| rest.split('/').next())
+                && !upstream.is_empty()
       {
           return match self
               .aliases
               .iter()
-                    .find(|alias| alias.name == uplink && alias.access.allows(identity))
+                    .find(|alias| alias.name == upstream && alias.access.allows(identity))
           {
               Some(alias) => RouteClass::Proxied {
                   alias: alias.name.clone(),
Evidence
The registry model introduced by this PR explicitly defines /~/ as the addressable surface for
hosted registries and routers, but the resolver route classifier continues to treat pnpr-origin
/~/ as an upstream-only endpoint and marks unknown/unauthorized names as Public. Pacquet’s
resolver uses the resulting MetadataCacheScope to pick shared vs private cache namespaces and
serves metadata from cache without a network fetch, so misclassifying a private registry route as
Public can leak private packuments into the shared cache and replay them to other callers.

pnpr/crates/pnpr/src/registry.rs[3-18]
pnpr/crates/pnpr/src/route.rs[317-354]
pnpr/crates/pnpr/src/route.rs[596-633]
pacquet/crates/network/src/auth.rs[63-83]
pacquet/crates/network/src/auth.rs[285-312]
pacquet/crates/resolving-npm-resolver/src/pick_package.rs[373-444]
pacquet/crates/resolving-npm-resolver/src/pick_package.rs[690-713]
pnpr/crates/pnpr/src/route/tests.rs[320-343]

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

## Issue description
After this PR, `/~<name>/` is the general **registry endpoint** (hosted, upstream, or router). But `RouteContext::classify()` still treats any pnpr-origin `/~<name>/...` fetch as an “upstream endpoint” lookup and falls back to `RouteClass::Public` when `<name>` is not an authorized upstream alias.
Because `RouteHook::metadata_scope()` maps `RouteClass::Public` to `MetadataCacheScope::Public`, this can place **private registry metadata** into the shared public mirror/cache keyspace. Pacquet’s resolver fast paths can then serve that metadata from memory/disk without an HTTP fetch (and thus without re-checking registry access/masking), creating a cross-caller confidentiality leak and possible authorization bypass on cache hits.
### Issue Context
- `/~<name>/` is now a registry surface for hosted registries and routers, not just upstream aliases.
- `MetadataCacheScope::Public` is explicitly shared across callers; private routes must be partitioned.
### Fix Focus Areas
- Update pnpr-origin `/~<name>/` classification to consult the **registry graph** (and hosted registry access visibility), so hosted/routers (and any private-by-definition endpoint) do **not** fall back to `Public`.
- Introduce/extend a private access descriptor that can namespace cache entries by **(registry id, package)** (or an equivalent unique identifier), so two different hosted registries with the same package name cannot collide.
- Update/replace the existing tests that assert “`/~<name>/` is upstream endpoint, never hosted”.
#### Files/lines to change
- pnpr/crates/pnpr/src/route.rs[317-354]
- pnpr/crates/pnpr/src/route.rs[596-633]
- pnpr/crates/pnpr/src/route/tests.rs[320-343]

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


3. Registry/upstream name collision ✓ Resolved 🐞 Bug ⛨ Security
Description
Config::ensure_valid_registry_graph folds Config::upstreams into Config::registries via
Registries::ensure_upstream, but ensure_upstream is a no-op when the name already exists as a
non-upstream registry, so programmatic configs can silently keep a Hosted/Router registry under an
upstream’s name. This can route /~/ traffic (and potentially publishes) to the wrong origin while
still carrying upstream serving config under the same identifier, violating the stated “fails closed
like YAML” invariant.
Code

pnpr/crates/pnpr/src/config.rs[R1578-1580]

+        for name in self.upstreams.keys() {
+            self.registries.ensure_upstream(name);
+        }
Evidence
The validation function unconditionally calls ensure_upstream for every upstream key, but
ensure_upstream only inserts when missing and does not validate the existing registry kind; YAML
graph building explicitly rejects such collisions, so programmatic configs can diverge from YAML
invariants.

pnpr/crates/pnpr/src/config.rs[1567-1581]
pnpr/crates/pnpr/src/registry.rs[269-278]
pnpr/crates/pnpr/src/config.rs[1708-1724]

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_valid_registry_graph` tries to make programmatic configs fail closed like YAML loads, but it currently does not detect a name collision where `Config::upstreams` contains a key that already exists in the registry graph as a **non-upstream** registry (hosted/router). This leaves ambiguous/incorrect routing for `/~<name>/` and can create unsafe operator/embedder footguns.
### Issue Context
- `ensure_valid_registry_graph` calls `Registries::ensure_upstream(name)` for each upstream key.
- `ensure_upstream` only inserts when the key is missing; it does **not** verify that an existing entry is `Registry::Upstream`.
- YAML loading *does* fail closed on collisions when building the graph.
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1577-1638]
- pnpr/crates/pnpr/src/registry.rs[269-278]
- pnpr/crates/pnpr/src/config.rs[1708-1724]
### What to change
1. In `Config::ensure_valid_registry_graph`, after (or instead of) `ensure_upstream`, validate that for every `name` in `self.upstreams.keys()`:
- `self.registries.get(name)` is `Some(Registry::Upstream { .. })`.
- If it exists but is `Hosted` or `Router`, return `RegistryError::InvalidConfig` with a collision message (mirroring `build_registries`’ collision failure).
2. (Optional but consistent) Also fail if `self.hosted` contains an entry whose name exists in `self.registries` but is not `Registry::Hosted { .. }`.
### Acceptance criteria
- Programmatic configs cannot have an upstream and a hosted/router registry sharing the same name.
- Behavior matches the YAML collision invariants (fail startup/reload loudly).

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


View more (3)
4. Credentialed upstream exposed publicly ✓ Resolved 🐞 Bug ⛨ Security
Description
Config::ensure_valid_registry_graph does not enforce the YAML invariant that a public upstream (no
access gate) must not send any custom headers, so a programmatic Config can set credential-bearing
headers with access=None and still pass startup validation. Because /~/ allows access when
access is None, this enables unauthenticated clients to use pnpr as a credentialed proxy to a
private upstream (confused-deputy).
Code

pnpr/crates/pnpr/src/config.rs[R1577-1623]

+    pub fn ensure_valid_registry_graph(&mut self) -> Result<(), RegistryError> {
+        for name in self.upstreams.keys() {
+            self.registries.ensure_upstream(name);
+        }
+        for name in self.registries.names() {
+            validate_registry_name(name)?;
+            // A concrete registry needs its serving config — a hosted graph
+            // entry without its `hosted` table row (or an upstream without
+            // its serving entry) would answer every request not-found at
+            // runtime. YAML loading builds both sides together; catch a
+            // programmatically-built mismatch at startup. Upstream backing
+            // is only required when the registry surface is enabled: a
+            // resolver-only tier deliberately skips upstream (credential)
+            // resolution and never serves `/~<name>/` content.
+            match self.registries.get(name) {
+                Some(Registry::Hosted { .. }) if !self.hosted.contains_key(name) => {
+                    return Err(RegistryError::InvalidConfig {
+                        reason: format!(
+                            "hosted registry {name:?} has no entry in the hosted serving table; \
+                             every request to it would be not-found",
+                        ),
+                    });
+                }
+                Some(Registry::Upstream { .. })
+                    if self.registry.enabled && !self.upstreams.contains_key(name) =>
+                {
+                    return Err(RegistryError::InvalidConfig {
+                        reason: format!(
+                            "upstream registry {name:?} has no serving config (URL, credentials); \
+                             every request to it would fail",
+                        ),
+                    });
+                }
+                _ => {}
+            }
+        }
+        for (index, (name, hosted)) in self.hosted.iter().enumerate() {
+            validate_registry_name(name)?;
+            validate_org_namespace(name, &hosted.org)?;
+            if let Some((other, _)) =
+                self.hosted.iter().take(index).find(|(_, existing)| existing.org == hosted.org)
+            {
+                return Err(org_collision_error(name, &hosted.org, other));
+            }
+        }
+        self.registries.validate().map_err(|err| registry_err(&err))
+    }
Evidence
ensure_valid_registry_graph validates names/graph/backing tables but never inspects upstream
header/access safety; programmatic upstream constructors allow arbitrary headers while leaving
access=None; and the request path explicitly treats access=None as publicly reachable at /~/,
while YAML loading has explicit guards against public+headers/auth and requires access for
non-public upstreams.

pnpr/crates/pnpr/src/config.rs[1567-1623]
pnpr/crates/pnpr/src/config.rs[521-551]
pnpr/crates/pnpr/src/server.rs[1232-1263]
pnpr/crates/pnpr/src/config.rs[1820-1867]

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_valid_registry_graph` is intended to make programmatic configs “fail closed like a YAML load”, but it currently validates only registry/hosted names and graph shape. It does **not** re-apply the YAML-only safety rules that prevent a publicly reachable upstream from carrying credential-bearing headers.
This lets an embedder create `UpstreamConfig { headers: <contains secrets>, access: None }` and pass `ensure_valid_registry_graph()`. At runtime, `/~<name>/...` permits access when `access` is `None`, so anyone can trigger requests that include the server-configured headers to the upstream.
### Issue Context
YAML configs are protected because `resolve_upstream_registry` rejects `public` upstreams with `auth`/`headers` and requires non-public upstreams to declare `access:`. Programmatic configs bypass `resolve_upstream_registry`, and `ensure_valid_registry_graph` currently does not compensate.
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1567-1623]
### Suggested fix
Add validation inside `ensure_valid_registry_graph` over `self.upstreams` mirroring the YAML invariants:
- If `upstream.access.is_none()` then require `upstream.headers.is_empty()` (and ideally no implicit auth material); otherwise return `RegistryError::InvalidConfig` explaining that a public upstream must not send custom headers.
- Optionally: if `!upstream.headers.is_empty()` then require `upstream.access.is_some()` to force an explicit access gate whenever any header (potentially an API key) is configured.
This keeps programmatic configs aligned with YAML safety behavior and prevents accidental public exposure of private upstream credentials.

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


5. Embedder validation bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
Config::ensure_valid_registry_graph is invoked from serve()/try_router_with_auth() for programmatic
configs, but it only folds uplinks into the graph and calls Registries::validate, skipping
validate_registry_name and validate_org_namespace that YAML loading enforces. This lets programmatic
configs create registry IDs that break /~/ and dist.tarball URL construction, and/or hosted
org values that can escape the storage root via simple path joining.
Code

pnpr/crates/pnpr/src/config.rs[R1573-1577]

+    pub fn ensure_valid_registry_graph(&mut self) -> Result<(), RegistryError> {
+        for name in self.uplinks.keys() {
+            self.registries.ensure_upstream(name);
+        }
+        self.registries.validate().map_err(|err| registry_err(&err))
Evidence
The new embedder-focused startup hook (ensure_valid_registry_graph) only performs graph
validation, while the YAML path additionally validates registry IDs as URL-safe segments and
validates hosted org to prevent filesystem root escape. The runtime then embeds registry IDs
directly into tarball URL bases and joins org directly into on-disk paths, so skipping these
checks makes unsafe embedder-supplied values exploitable/misbehaving.

pnpr/crates/pnpr/src/config.rs[1564-1578]
pnpr/crates/pnpr/src/config.rs[1648-1707]
pnpr/crates/pnpr/src/config.rs[1710-1751]
pnpr/crates/pnpr/src/server.rs[1225-1230]
pnpr/crates/pnpr/src/storage.rs[303-312]
pnpr/crates/pnpr/src/storage.rs[472-482]

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_valid_registry_graph()` is intended to make programmatically-constructed configs fail closed like YAML-loaded configs, but it currently does **not** enforce the same URL/path-safety invariants as the YAML build path.
Specifically, it inserts uplinks into `self.registries` and runs `Registries::validate()`, but it never calls:
- `validate_registry_name()` (URL-safe `/~<name>/` segment + safe `dist.tarball` rewrite base)
- `validate_org_namespace()` (prevents hosted `org` from escaping storage root)
As a result, embedders can accidentally or intentionally create unsafe registry IDs and/or hosted org namespaces that would be rejected in YAML mode.
### Issue Context
- YAML loading enforces name/org safety during `build_registries()`.
- Runtime uses registry IDs verbatim in URL construction (`uplink_tarball_base`) and uses hosted `org` verbatim as a filesystem path segment (`Store::namespaced` via `Storage::for_hosted`).
### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1564-1578]
### Suggested fix
1. In `ensure_valid_registry_graph()`:
- For every `self.uplinks` key, call `validate_registry_name(name)?` before `self.registries.ensure_upstream(name)`.
- For every hosted registry entry in `self.hosted`:
- call `validate_registry_name(hosted_registry_id)?`
- call `validate_org_namespace(hosted_registry_id, &hosted.org)?`
- replicate the YAML-path `org` collision check (two hosted registries must not share the same `org`).
2. Consider extending `Registries` with an iterator over defined registry IDs so `ensure_valid_registry_graph()` can also validate IDs for embedders that build `self.registries` directly (not just via uplinks/hosted tables).
3. Keep `self.registries.validate()` after these checks so graph-level invariants are still enforced.

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


6. Invalid scope pattern allowed 🐞 Bug ⛨ Security
Description
PackagePattern::parse accepts @/* without validating that `` is a valid package-name scope, so
patterns like @.acme/* or @../* can never match any valid request name. This can silently drop
an intended private-scope claim and let a later router source (often public) serve the package
instead, enabling dependency confusion/misrouting under a mistaken configuration.
Code

pnpr/crates/pnpr/src/registry.rs[R72-78]

+        if let Some(scope) = pattern.strip_prefix('@').and_then(|rest| rest.strip_suffix("/*")) {
+            // A single, concrete scope: `@acme/*`. The scope itself may not
+            // contain a wildcard or a further path separator.
+            if scope.is_empty() || scope.contains('*') || scope.contains('/') {
+                return Err(invalid());
+            }
+            return Ok(PackagePattern::Scope(scope.to_string()));
Evidence
The scope-wildcard parse branch only checks for * and /, so it can accept scopes that the
request-side PackageName::parse will always reject; such patterns can never match and therefore
silently remove a routing claim the operator intended to enforce.

pnpr/crates/pnpr/src/registry.rs[61-88]
pnpr/crates/pnpr/src/package_name.rs[15-34]
pnpr/crates/pnpr/src/package_name.rs[82-90]

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

## Issue description
`PackagePattern::parse` validates exact-name patterns via `PackageName::parse`, but the `@<scope>/*` branch only rejects `*` and `/`. This allows scope patterns that no valid `PackageName` can ever produce (e.g. scopes starting with `.` or equal to `..`), creating a "dead" namespace claim that can silently alter routing.
### Issue Context
Real requests are parsed with `PackageName::parse`, which rejects unsafe scope segments; therefore, a scope wildcard pattern should be rejected at config load if its scope would be rejected by `PackageName::parse`.
### Fix Focus Areas
- pnpr/crates/pnpr/src/registry.rs[61-88]
- pnpr/crates/pnpr/src/package_name.rs[15-34]
### Implementation notes
- In the `@<scope>/*` parsing branch, validate `scope` using the same rules as request package parsing.
- Minimal approach: attempt `PackageName::parse(&format!("@{scope}/x"))` (with a known-safe name like `x`) and treat `Err` as `InvalidPattern`.
- Alternatively, expose a small reusable helper from `package_name` for validating a segment and call it here.
- Add/adjust tests to ensure invalid scope patterns are rejected (e.g. `@.acme/*`, `@../*`).

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



Remediation recommended

7. Publish reveals hidden namespaces 🐞 Bug ⛨ Security
Description
resolve_publish_target returns a 400 for unclaimed names when registry_visible_to_caller(target) is
true, but routers are considered “visible” if any source is visible. This lets a caller distinguish
“unclaimed” (400) from “claimed by an inaccessible hosted source” (404), enabling enumeration of
private scope/package patterns behind an otherwise usable router.
Code

pnpr/crates/pnpr/src/server.rs[R2442-2451]

+        RegistrySource::Unclaimed => {
+            if registry_visible_to_caller(state, identity, &target) {
+                PublishTarget::Reject(format!(
+                    "cannot publish {package:?} {context}: no registry's declared `patterns:` \
+                     claim this package name",
+                ))
+            } else {
+                PublishTarget::NotFound
+            }
+        }
Evidence
The publish routing returns 404 when a name resolves to a hosted registry the caller can’t access,
but returns 400 for Unclaimed when the *router* is deemed visible; since router visibility is
any(source visible), a mixed router yields a 400/404 side-channel for private-pattern membership.

pnpr/crates/pnpr/src/server.rs[2406-2453]
pnpr/crates/pnpr/src/server.rs[2462-2474]
pnpr/crates/pnpr/src/server.rs[1893-1905]

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

## Issue description
`resolve_publish_target()` returns a detailed 400 rejection for `RegistrySource::Unclaimed` whenever `registry_visible_to_caller()` returns true. For routers, `registry_visible_to_caller()` currently returns true if **any** source is visible, which makes a router with mixed visibility leak whether a probed package name is claimed by a hidden hosted registry (404) vs truly unclaimed (400).
## Issue Context
This leak matters specifically when a router is usable/visible via at least one public source (often an upstream), but also contains at least one hosted source hidden by its access list. A caller who can reach the publish endpoint can then probe names and infer private pattern membership.
## Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[2406-2475]
Suggested direction:
- Change router visibility for the purpose of emitting the detailed `Unclaimed` rejection so that a router is considered “visible” only when **all** its concrete sources are visible to the caller (or at minimum, all hosted sources are visible). This makes `Unclaimed` return 404 in mixed-visibility routers, removing the oracle.
- Keep current behavior for concrete registries (hosted/upstream) as-is.

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


8. Duplicate registry resolution 🐞 Bug ➹ Performance
Description
Path-less packument/version handlers resolve the default registry and package twice (serve_*
resolves via resolve_registry_source, then private_if_caller_gated re-resolves to decide cache
headers), adding avoidable CPU work and String allocations on the hot install path. This is
especially wasteful under load because every GET /<pkg> and GET /<pkg>/<version> does the extra
resolution even when the response is already determined.
Code

pnpr/crates/pnpr/src/server.rs[R1799-1806]

if acl_gated {
return private_no_cache(response);
}
-    match default_target_mount(state) {
+    match default_registry_target(state) {
Some(target) if resolves_to_private_source(state, &target, package) => {
private_no_cache(response)
}
_ => response,
Evidence
The path-less handlers first pick a default target and serve via registry dispatch; cache header
logic then re-fetches the default target and re-runs resolution/privacy checks, causing duplicated
work per request.

pnpr/crates/pnpr/src/server.rs[1115-1131]
pnpr/crates/pnpr/src/server.rs[1744-1785]
pnpr/crates/pnpr/src/server.rs[1796-1807]

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 path-less handlers (e.g. `serve_packument`, `serve_version_manifest`) already route through the registry graph to serve the response, but then `private_if_caller_gated` calls `default_registry_target` + `resolves_to_private_source` which calls `resolve_registry_source` again. This duplicates routing/pattern checks and performs additional `String` allocations on a hot request path.
### Issue Context
This impacts the path-less base (`https://<pnpr>/...`) which is described in-code as “the hot install path”, so even small inefficiencies can show up in throughput/latency under load.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1115-1155]
- pnpr/crates/pnpr/src/server.rs[1744-1807]
### Suggested fix approach
- Thread the already-chosen default target (and/or the already-resolved `RegistrySource`) through the path-less serving flow so cacheability can be decided without re-resolving.
- Option A (minimal): change `private_if_caller_gated` to accept `default_target: &str` so it doesn’t call `default_registry_target` again.
- Option B (better): have `serve_registry_packument` / `serve_registry_version_manifest` return `(Response, bool caller_gated_source)` (or a small struct) computed from the same `RegistrySource` match, and let the path-less wrapper apply `private_no_cache` based on that flag + per-package ACL, avoiding the second `resolve_registry_source` call entirely.
- Also consider returning borrowed registry ids from `resolve_registry_source` (or using `Cow<'a, str>`) to avoid per-request `to_string()` where ownership isn’t required.

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


9. Upstream access leaks registry 🐞 Bug ⛨ Security
Description
Requests routed to a private upstream registry return 403 on access denial, while unknown registries
return 404, letting an attacker probe /~<name>/ to enumerate private upstream registry IDs (for
package names claimed by that registry’s patterns). This contradicts the code comment that private
registry names are "carefully mask[ed]" and creates an existence oracle.
Code

pnpr/crates/pnpr/src/server.rs[R1745-1764]

+fn resolve_registry_source(state: &AppState, registry: &str, package: &str) -> RegistrySource {
+    match state.inner.config.registries.resolve(registry, package) {
+        Resolved::Concrete { registry, kind: ConcreteKind::Upstream } => {
+            RegistrySource::Upstream(registry.to_string())
}
-        Resolved::Concrete { mount, kind: ConcreteKind::Hosted } => {
-            MountSource::Hosted(mount.to_string())
+        Resolved::Concrete { registry, kind: ConcreteKind::Hosted } => {
+            RegistrySource::Hosted(registry.to_string())
}
-        // A router that matched no route is a definitive not-found — never a
-        // fall-through to another origin.
-        Resolved::NoRoute => MountSource::NotFound,
-        // Every configured uplink is an upstream mount addressable at
+        // An unclaimed name is definitive — never a fall-through to another
+        // origin, and never a storage or upstream consultation.
+        Resolved::Unclaimed => RegistrySource::Unclaimed,
+        // Every configured uplink is an upstream registry addressable at
// `/~<uplink>/`, even one inserted programmatically without rebuilding
-        // the mount graph; fall back to that before declaring not-found.
-        Resolved::UnknownMount => {
-            if state.inner.config.uplinks.contains_key(mount) {
-                MountSource::Upstream(mount.to_string())
+        // the registry graph; fall back to that before declaring not-found.
+        Resolved::UnknownRegistry => {
+            if state.inner.config.uplinks.contains_key(registry) {
+                RegistrySource::Upstream(registry.to_string())
} else {
-                MountSource::NotFound
+                RegistrySource::NotFound
}
Evidence
The router code comment states that differing auth vs not-found responses would create an existence
oracle for private registry names. However, authorized_uplink explicitly returns a 403 Forbidden
when access denies the caller, and resolve_registry_source routes requests to upstream
registries, making the 403 observable and distinguishable from unknown registries (404).

pnpr/crates/pnpr/src/server.rs[294-299]
pnpr/crates/pnpr/src/server.rs[1229-1259]
pnpr/crates/pnpr/src/server.rs[1745-1766]

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

## Issue description
Private upstream registries currently return a different HTTP status for “exists but access denied” (403) vs “registry id does not exist” (404). With the new registry routing (`resolve_registry_source`), this becomes a stable existence oracle for private upstream registry IDs (for any package name that is within the registry’s declared `patterns:`).
### Issue Context
- The server explicitly avoids creating a 401-vs-404 oracle in the identity endpoints, stating that content handlers “carefully mask” private registry names.
- `authorized_uplink()` returns a `RegistryError::Forbidden` response when the registry’s `access:` policy denies the caller.
- `resolve_registry_source()` routes `/~<name>/...` to upstream registries (including private ones), so this 403 is observable at the public registry surface.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1745-1766]
- pnpr/crates/pnpr/src/server.rs[1229-1259]
- pnpr/crates/pnpr/src/server.rs[294-299]
### Suggested fix
- For upstream registries with `access:` (private upstreams), return `404 Not Found` (same as unknown registry) when the caller is not admitted, matching the hosted-registry masking behavior.
- Keep observability by logging the denied access (server-side), but do not reveal it via status code.
- Add/adjust an e2e test asserting that denied access to a private upstream via `/~<name>/...` is indistinguishable from an unknown registry (status and body), for an in-pattern package name.

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


View more (3)
10. Search routing allocates strings 🐞 Bug ➹ Performance
Description
resolve_registry_source allocates new Strings (to_string()) for every resolution and is called
inside the per-candidate search filter closure, creating an allocation per scanned package name.
This adds avoidable CPU/allocator overhead in search and other synchronous routing checks, even
though Registries::resolve already returns borrowed registry ids.
Code

pnpr/crates/pnpr/src/server.rs[R1745-1752]

+fn resolve_registry_source(state: &AppState, registry: &str, package: &str) -> RegistrySource {
+    match state.inner.config.registries.resolve(registry, package) {
+        Resolved::Concrete { registry, kind: ConcreteKind::Upstream } => {
+            RegistrySource::Upstream(registry.to_string())
}
-        Resolved::Concrete { mount, kind: ConcreteKind::Hosted } => {
-            MountSource::Hosted(mount.to_string())
+        Resolved::Concrete { registry, kind: ConcreteKind::Hosted } => {
+            RegistrySource::Hosted(registry.to_string())
}
-        // A router that matched no route is a definitive not-found — never a
-        // fall-through to another origin.
-        Resolved::NoRoute => MountSource::NotFound,
-        // Every configured uplink is an upstream mount addressable at
Evidence
The server helper allocates Strings for resolved ids, and the search path invokes that helper for
each scanned name; meanwhile the underlying registry graph resolver already provides borrowed ids
suitable for synchronous comparisons.

pnpr/crates/pnpr/src/server.rs[1741-1767]
pnpr/crates/pnpr/src/server.rs[2947-2952]
pnpr/crates/pnpr/src/registry.rs[262-309]

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

## Issue description
`resolve_registry_source` converts borrowed resolved ids into owned `String`s on every call. In `serve_search`, it is invoked for each candidate name via the `keep` closure, causing per-candidate allocations.
### Issue Context
`Registries::resolve` returns `Resolved::Concrete { registry: &'a str, ... }`, so synchronous routing-only checks can use borrowed ids and avoid allocations.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1741-1767]
- pnpr/crates/pnpr/src/server.rs[2947-2952]
- pnpr/crates/pnpr/src/registry.rs[262-309]
### Implementation notes
- In `serve_search`'s `keep` closure, call `state.inner.config.registries.resolve(&registry, name)` directly and compare the borrowed `registry` id to `source` (no allocation).
- Optionally split routing helpers into:
- a borrowed resolver for synchronous paths (search, cache-header decisions), and
- an owned resolver only where you must hold the result across `await`.
- If you keep `resolve_registry_source`, consider making it allocate only when needed (e.g., convert to owned right before awaiting an async call).

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


11. Uplink fallback skips namespace ✓ Resolved 🐞 Bug ⛨ Security
Description
resolve_mount_source falls back from Resolved::UnknownMount to serving any Config::uplinks
entry as an upstream mount, so /~/... can be served without the mount being present in
Config::mounts and without any mount-level patterns/graph validation applying. Embedders that
insert uplinks programmatically (e.g. pacquet’s pnpr-client tests) can unintentionally expose an
unbounded, credentialed upstream surface with no way to declare/force a namespace bound.
Code

pnpr/crates/pnpr/src/server.rs[R1752-1754]

// Every configured uplink is an upstream mount addressable at
// `/~<uplink>/`, even one inserted programmatically without rebuilding
// the mount graph; fall back to that before declaring not-found.
Evidence
The server explicitly routes Resolved::UnknownMount to an upstream if the id exists in
Config::uplinks, bypassing mount-graph enforcement. The pacquet integration harness inserts
uplinks into config.uplinks without rebuilding config.mounts, so it exercises this bypass path.
In contrast, YAML config loading builds upstream mounts (with parsed patterns) into the mount
graph and calls Mounts::validate(), so the fallback is specifically the path that can avoid the
new mount-level namespace/validation checks.

pnpr/crates/pnpr/src/server.rs[1741-1761]
pacquet/crates/pnpr-client/tests/integration.rs[71-86]
pnpr/crates/pnpr/src/config.rs[1622-1687]

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

## Issue description
`resolve_mount_source()` treats an `UnknownMount` as an upstream mount when `config.uplinks` contains the same key. With the new design, upstream namespace bounds (`patterns`) live only in the mount graph (`Config::mounts`) and are enforced by `Mounts::resolve()`, so this fallback creates a bypass path where `/~<uplink>/` traffic can avoid mount-graph validation and any pattern enforcement.
### Issue Context
This especially affects embedders that mutate `Config::uplinks` after constructing a config (without rebuilding `Config::mounts`). The repo already does this in pacquet’s pnpr-client integration tests.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1741-1761]
- pacquet/crates/pnpr-client/tests/integration.rs[71-86]
- pnpr/crates/pnpr/src/config.rs[1622-1687]
### Suggested fix
Choose one (in descending preference for preserving the new security model):
1) **Fail closed:** remove the `UnknownMount`→`uplinks.contains_key()` fallback, and require every addressable upstream to exist in `Config::mounts` (which already happens for YAML-loaded configs via `build_mounts`). Update embedders/tests to insert both:
- `config.uplinks.insert(name, uplink)`
- `config.mounts` updated to include `MountKind::Upstream { patterns: ... }`, followed by `config.mounts.validate()`.
2) **Provide a safe API:** add a helper like `Config::insert_uplink_mount(name, uplink, patterns)` that updates both `uplinks` and `mounts` (and validates), then migrate pacquet’s usage to this helper.
3) **If keeping fallback:** explicitly document that uplinks-only mounts are always pattern-less catch-alls and cannot be bounded, and consider rejecting `access`-bearing uplinks unless they are also present in the mount graph (to prevent accidental credential exposure).

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


12. Unclaimed publish leaks mount ✓ Resolved 🐞 Bug ⛨ Security
Description
resolve_publish_target maps an off-pattern name (MountSource::Unclaimed) to a 400 BadRequest before
any hosted-mount access masking, so an unauthorized caller can probe /~<mount>/ with an unclaimed
name and distinguish an existing private mount (400) from an unknown mount (404). This breaks the
stated “treat denied caller as not-found” guarantee for private hosted mounts and enables mount
enumeration.
Code

pnpr/crates/pnpr/src/server.rs[R2407-2411]

+        MountSource::Unclaimed => PublishTarget::Reject(format!(
+            "cannot publish {package:?} {context}: no mount's declared `patterns:` claim this \
+             package name",
+        )),
MountSource::NotFound => PublishTarget::NotFound,
Evidence
resolve_publish_target converts MountSource::Unclaimed to a Reject (400) without checking hosted
mount access, while accessible_hosted_namespace explicitly documents that denied callers must be
treated as not-found to avoid revealing private org existence. Mounts::resolve produces
Unclaimed purely from pattern mismatch, so this 400 can occur for private mounts and is observable
as a 400 vs 404 difference.

pnpr/crates/pnpr/src/server.rs[2381-2424]
pnpr/crates/pnpr/src/server.rs[1879-1910]
pnpr/crates/pnpr/src/mount.rs[255-279]

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

## Issue description
`resolve_publish_target()` returns **400** for `MountSource::Unclaimed` even when the addressed mount is a **private hosted mount** that the caller is not allowed to access. This lets unauthorized callers distinguish “mount exists but name is off-pattern” (400) from “mount does not exist” (404), violating the code’s stated existence-masking behavior for private hosted mounts.
### Issue Context
- `Mounts::resolve()` returns `Resolved::Unclaimed` solely based on pattern coverage (independent of access control).
- `resolve_publish_target()` currently turns `MountSource::Unclaimed` into `PublishTarget::Reject(...)` (400), while a denied hosted mount becomes `PublishTarget::NotFound` (404) only when the resolution is `MountSource::Hosted` and `accessible_hosted_namespace()` denies.
### Fix Focus Areas
- Make publish/dist-tag/unpublish/packument-update writes return **404** (NotFound) when the caller cannot access the addressed hosted mount, **even if** the package name is off-pattern.
- Concretely: when `mount: Some(mount)` is provided, check whether `mount` is a hosted mount and whether `accessible_hosted_namespace(state, identity, mount)` is `None`; if so, return `PublishTarget::NotFound` before calling `resolve_mount_source()` / before converting `Unclaimed` into a 400.
- pnpr/crates/pnpr/src/server.rs[2392-2471]

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



Informational

13. Misleading upstream auth doc ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The rustdoc for authorized_upstream claims an upstream "with no access: policy" is a 404, but
the implementation treats access: None as public/reachable and only gates when access is
present. This mismatch can mislead future changes around upstream visibility and masking semantics.
Code

pnpr/crates/pnpr/src/server.rs[R1232-1236]

+/// Resolve the upstream behind an authorized `/~<name>/` endpoint request.
///
-/// Fails closed: an uplink that does not exist or carries no `access:` policy
+/// Fails closed: an upstream that does not exist or carries no `access:` policy
/// is a `404` (it is not a private-route endpoint), and a caller the policy
/// does not admit is a `403`. Returns the [`Upstream`] to fetch *through* —
-/// `/~<uplink>/` requests never read or write the shared proxy mirror, so a
-/// private uplink's packuments and tarballs can never leak across the public
-/// path or another uplink.
-fn authorized_uplink<'a>(
Evidence
The doc states lack of access: causes a 404, but the code path only checks access when present
and otherwise proceeds, explicitly commenting that public upstreams (no access) are reachable.

pnpr/crates/pnpr/src/server.rs[1232-1253]

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

## Issue description
`authorized_upstream`'s docstring contradicts its behavior: the code allows `access=None` (public upstream) but the doc says that case 404s.
### Issue Context
This function is used broadly for `/~<name>/` upstream serving and router resolution; documenting the correct semantics matters to avoid accidental security/behavior regressions later.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1232-1263]
### Suggested fix approach
- Update the doc to reflect actual semantics (e.g. “unknown upstream is 404; if `access` is set and denies the caller, return 403; `access=None` means public”).
- Alternatively, if the doc reflects intended behavior, change implementation accordingly (but that would be a behavior change and should be justified/tested).

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


14. Upstream namespace clones per request 🐞 Bug ➹ Performance
Description
upstream_cache_namespace clones the cached namespace String on every upstream packument/tarball
request, adding avoidable heap allocations on a hot path. The namespace can be returned as a borrow
(or Cow) because it is stored in AppInner and lives long enough across awaits.
Code

pnpr/crates/pnpr/src/server.rs[R1269-1275]

+fn upstream_cache_namespace(state: &AppState, upstream: &str) -> String {
state
.inner
-        .uplink_cache_namespaces
-        .get(uplink)
+        .upstream_cache_namespaces
+        .get(upstream)
.cloned()
-        .unwrap_or_else(|| compute_uplink_cache_namespace(&state.inner.config, uplink))
+        .unwrap_or_else(|| compute_upstream_cache_namespace(&state.inner.config, upstream))
Evidence
The helper clones the namespace string, and it is used in upstream packument loads on each request,
so the clone happens per request in upstream-proxy traffic.

pnpr/crates/pnpr/src/server.rs[1265-1276]
pnpr/crates/pnpr/src/server.rs[1451-1461]

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

## Issue description
`upstream_cache_namespace` currently does `.cloned()` from `AppInner::upstream_cache_namespaces`, allocating a new `String` per request. This helper is called on every upstream packument/tarball serve, so this adds allocator/CPU overhead on a hot path.
### Issue Context
Callers only need an `&str` namespace to pass into storage/cache helpers; the namespace is already stored in `AppInner`.
### Fix Focus Areas
- pnpr/crates/pnpr/src/server.rs[1265-1276]
- pnpr/crates/pnpr/src/server.rs[1451-1463]
### What to change
- Change `upstream_cache_namespace` to return `Cow<'a, str>` (or `&'a str` if you’re comfortable removing the fallback path).
- `Cow::Borrowed(ns)` when present in `upstream_cache_namespaces`.
- `Cow::Owned(compute_upstream_cache_namespace(...))` only in the (should-not-happen) fallback.
- Update call sites to pass `namespace.as_ref()`.
### Acceptance criteria
- No per-request `String` clone on cache-hit paths.
- Behavior unchanged for fallback computation.

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


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 registry-mount routing model to the revised design from pnpm/rfcs#16: patterns are declared on concrete mounts (hosted/upstream) and routers become ordered sources that select the first source whose declared namespace claims a package.

Changes:

  • Move package-name pattern declarations onto concrete mounts (Hosted / Upstream) and replace router routes with ordered sources.
  • Enforce declared namespaces in mount resolution (reads 404 early; publishes reject off-pattern names), update server dispatch and config parsing accordingly.
  • Update bundled config.yaml and rework/unit/e2e tests to cover new validation and enforcement behavior.

Reviewed changes

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

Show a summary per file
File Description
pnpr/crates/pnpr/tests/server.rs Updates server integration tests for mount-level patterns + router sources; adds e2e coverage for off-pattern behavior.
pnpr/crates/pnpr/tests/policy.rs Updates YAML test fixture to new router sources shape.
pnpr/crates/pnpr/tests/auth_publish.rs Updates YAML test fixture to new router sources shape.
pnpr/crates/pnpr/src/server.rs Implements Unclaimed resolution semantics and adjusts server request/publish/search routing accordingly.
pnpr/crates/pnpr/src/mount/tests.rs Reworks mount unit tests for new mount/namespace model and router validation rules.
pnpr/crates/pnpr/src/mount.rs Core model change: patterns on mounts, routers as ordered sources; new validation for namespaces/sources.
pnpr/crates/pnpr/src/lib.rs Updates public re-exports to remove removed Route type.
pnpr/crates/pnpr/src/config/tests.rs Updates config parsing tests for mount patterns and router sources, and adds new validation tests.
pnpr/crates/pnpr/src/config.rs Updates YAML schema + config graph building to compile mount patterns and router source lists.
pnpr/crates/pnpr/config.yaml Updates bundled config to declare fixture namespace on local mount and use router sources.

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

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

Copy link
Copy Markdown

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

@codecov-commenter

codecov-commenter commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.85185% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.59%. Comparing base (9ef4c01) to head (7fa37d4).

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/server.rs 87.98% 28 Missing ⚠️
pnpr/crates/pnpr/src/registry.rs 91.66% 20 Missing ⚠️
pnpr/crates/pnpr/src/config.rs 95.65% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12778      +/-   ##
==========================================
+ Coverage   85.56%   85.59%   +0.02%     
==========================================
  Files         413      413              
  Lines       63986    64125     +139     
==========================================
+ Hits        54750    54888     +138     
- Misses       9236     9237       +1     

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

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Commit: 8851f26ad8f2

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 4.133 ± 0.239 3.921 4.580 1.91 ± 0.17
pacquet@main 3.917 ± 0.028 3.872 3.959 1.81 ± 0.12
pnpr@HEAD 2.164 ± 0.141 1.944 2.378 1.00
pnpr@main 2.173 ± 0.153 1.960 2.380 1.00 ± 0.10
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.132803087959999,
      "stddev": 0.23893096917329243,
      "median": 4.00712985986,
      "user": 3.8980312399999995,
      "system": 3.5191763799999998,
      "min": 3.92098871686,
      "max": 4.57966316986,
      "times": [
        4.02121979086,
        4.51175222986,
        3.9822780038600003,
        3.92098871686,
        4.57966316986,
        3.9930399288600005,
        3.9661987358600004,
        4.17598211386,
        4.22528040986,
        3.9516277798600004
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.91748550716,
      "stddev": 0.028483618457604817,
      "median": 3.91430816986,
      "user": 3.8935976399999994,
      "system": 3.47356438,
      "min": 3.8724047828600003,
      "max": 3.9587695638600002,
      "times": [
        3.91672684786,
        3.92404722386,
        3.9071245658600002,
        3.87986822986,
        3.9118894918600002,
        3.9587695638600002,
        3.9348945588600004,
        3.91131236286,
        3.8724047828600003,
        3.9578174438600002
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.16410423726,
      "stddev": 0.1409353340985199,
      "median": 2.19553172836,
      "user": 2.70169914,
      "system": 3.0266663799999995,
      "min": 1.94411268786,
      "max": 2.37759562986,
      "times": [
        2.27779753286,
        2.22252947686,
        2.24455834986,
        2.06304695686,
        1.94411268786,
        2.37759562986,
        2.02804877986,
        2.02867604886,
        2.1685339798600003,
        2.28614292986
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1728785440600005,
      "stddev": 0.15272441729036004,
      "median": 2.14095810186,
      "user": 2.72830294,
      "system": 3.0338863799999998,
      "min": 1.96015110786,
      "max": 2.37987659186,
      "times": [
        2.11530405286,
        2.37987659186,
        2.1666121508600003,
        2.0613356568600003,
        2.08294907986,
        2.35305914486,
        1.96015110786,
        2.0208295288600002,
        2.37449490986,
        2.2141732168600003
      ]
    }
  ]
}

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

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 664.8 ± 7.7 652.0 678.7 1.01 ± 0.04
pacquet@main 659.1 ± 23.0 630.2 698.5 1.00
pnpr@HEAD 713.2 ± 60.5 673.8 881.8 1.08 ± 0.10
pnpr@main 757.3 ± 117.6 673.9 1084.7 1.15 ± 0.18
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6648153428400001,
      "stddev": 0.007711800992298111,
      "median": 0.66599094424,
      "user": 0.39193982,
      "system": 1.3550160799999997,
      "min": 0.6519938337400001,
      "max": 0.6787018597400001,
      "times": [
        0.65422389674,
        0.66140317674,
        0.66874148274,
        0.6701242727400001,
        0.66733635274,
        0.66364666474,
        0.6519938337400001,
        0.6787018597400001,
        0.6657520637400001,
        0.66622982474
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.65907774184,
      "stddev": 0.023008274828784935,
      "median": 0.6555649752400001,
      "user": 0.39166782,
      "system": 1.3468110799999997,
      "min": 0.63019432574,
      "max": 0.69845219774,
      "times": [
        0.67969501474,
        0.63019432574,
        0.6374527427400001,
        0.6697477767400001,
        0.6635430357400001,
        0.6456738977400001,
        0.69845219774,
        0.68203664174,
        0.6475869147400001,
        0.63639487074
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.71324242194,
      "stddev": 0.06045258298931623,
      "median": 0.69746904674,
      "user": 0.39846851999999994,
      "system": 1.3676737799999998,
      "min": 0.67378063874,
      "max": 0.8818238927400001,
      "times": [
        0.71213005974,
        0.68087737674,
        0.6926342577400001,
        0.68640825874,
        0.67378063874,
        0.70230383574,
        0.70438872774,
        0.70674078474,
        0.6913363867400001,
        0.8818238927400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.75734462784,
      "stddev": 0.11764972416361932,
      "median": 0.7311573032400001,
      "user": 0.41714182000000005,
      "system": 1.3684525799999998,
      "min": 0.6738796957400001,
      "max": 1.0847374977400002,
      "times": [
        0.73672752474,
        0.73714935274,
        0.6932996907400001,
        0.7069941767400001,
        0.7418171477400001,
        0.7255870817400001,
        0.7161000457400001,
        0.6738796957400001,
        1.0847374977400002,
        0.75715406474
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.210 ± 0.043 4.153 4.276 1.90 ± 0.11
pacquet@main 4.223 ± 0.050 4.154 4.307 1.91 ± 0.11
pnpr@HEAD 2.226 ± 0.128 2.004 2.442 1.01 ± 0.08
pnpr@main 2.213 ± 0.126 1.966 2.362 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.209638357920001,
      "stddev": 0.042801835375105624,
      "median": 4.20522365952,
      "user": 3.81144794,
      "system": 3.41113402,
      "min": 4.15286857952,
      "max": 4.27632091552,
      "times": [
        4.25874273552,
        4.23682876352,
        4.20495451052,
        4.16168652952,
        4.16360877952,
        4.19477764952,
        4.20549280852,
        4.24110230752,
        4.27632091552,
        4.15286857952
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.22348999102,
      "stddev": 0.04997491949507371,
      "median": 4.21914707802,
      "user": 3.82117284,
      "system": 3.3811452199999996,
      "min": 4.15391399652,
      "max": 4.30679720052,
      "times": [
        4.30679720052,
        4.15618394052,
        4.23137876252,
        4.20840104352,
        4.24527048552,
        4.20231176652,
        4.29234855852,
        4.15391399652,
        4.22848459152,
        4.2098095645199995
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.2262647297200004,
      "stddev": 0.1278582390548118,
      "median": 2.23022324502,
      "user": 2.53762944,
      "system": 2.9155545199999997,
      "min": 2.00435848852,
      "max": 2.44213310352,
      "times": [
        2.21926058152,
        2.06325200452,
        2.22602393752,
        2.00435848852,
        2.44213310352,
        2.27970930052,
        2.1617972605199998,
        2.34357205752,
        2.23442255252,
        2.28811801052
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.2126045549200004,
      "stddev": 0.12620057544592125,
      "median": 2.20397561352,
      "user": 2.5281956399999994,
      "system": 2.9032915199999993,
      "min": 1.96626067252,
      "max": 2.36205676252,
      "times": [
        2.17832078552,
        2.32162471352,
        2.1224110015199997,
        2.22963044152,
        2.35369061152,
        2.1302530485199997,
        1.96626067252,
        2.30664855852,
        2.36205676252,
        2.15514895352
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.349 ± 0.011 1.328 1.361 2.00 ± 0.07
pacquet@main 1.365 ± 0.023 1.337 1.424 2.02 ± 0.08
pnpr@HEAD 0.676 ± 0.023 0.649 0.731 1.00
pnpr@main 0.695 ± 0.080 0.660 0.922 1.03 ± 0.12
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.34863816302,
      "stddev": 0.010731992671848637,
      "median": 1.35004765892,
      "user": 1.3438190999999997,
      "system": 1.70457026,
      "min": 1.32773397142,
      "max": 1.36140856942,
      "times": [
        1.35859176642,
        1.34570581842,
        1.36140856942,
        1.35155780642,
        1.3369724274199999,
        1.32773397142,
        1.34272457042,
        1.3531634854199999,
        1.34853751142,
        1.35998570342
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3645345782199998,
      "stddev": 0.02263309734664884,
      "median": 1.36034160192,
      "user": 1.3635941999999999,
      "system": 1.6975356599999998,
      "min": 1.33732899142,
      "max": 1.42390719242,
      "times": [
        1.35413149742,
        1.36242232342,
        1.42390719242,
        1.35272851242,
        1.36111581042,
        1.35878686542,
        1.36906186742,
        1.33732899142,
        1.35956739342,
        1.36629532842
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6756159211199999,
      "stddev": 0.023124297273371616,
      "median": 0.66982695442,
      "user": 0.3503142,
      "system": 1.30119806,
      "min": 0.64862165642,
      "max": 0.73129544542,
      "times": [
        0.66022618542,
        0.64862165642,
        0.69208660942,
        0.66471746542,
        0.67551407642,
        0.66524694942,
        0.73129544542,
        0.66148690842,
        0.6744069594200001,
        0.68255695542
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6949399138200001,
      "stddev": 0.08000637164866568,
      "median": 0.67040770842,
      "user": 0.36038479999999995,
      "system": 1.3065599599999997,
      "min": 0.6604173894200001,
      "max": 0.92198416442,
      "times": [
        0.67391511042,
        0.66477660842,
        0.66829120342,
        0.66359021442,
        0.68042939542,
        0.67252421342,
        0.66764039442,
        0.6604173894200001,
        0.6758304444200001,
        0.92198416442
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.071 ± 0.046 3.013 3.184 4.50 ± 0.11
pacquet@main 3.007 ± 0.058 2.968 3.160 4.41 ± 0.12
pnpr@HEAD 0.688 ± 0.013 0.664 0.707 1.01 ± 0.03
pnpr@main 0.682 ± 0.013 0.666 0.706 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0708536526600003,
      "stddev": 0.04606240923441474,
      "median": 3.06598358806,
      "user": 1.87882106,
      "system": 1.96899482,
      "min": 3.01280308806,
      "max": 3.18375413906,
      "times": [
        3.0672935210600003,
        3.0954390220600003,
        3.01280308806,
        3.0290214730600002,
        3.0673695490600004,
        3.06467365506,
        3.0645306690600003,
        3.07522265806,
        3.18375413906,
        3.04842875206
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.0065092836600003,
      "stddev": 0.05840934056330872,
      "median": 2.98630121006,
      "user": 1.82761846,
      "system": 1.9691463199999997,
      "min": 2.96774719406,
      "max": 3.15988444706,
      "times": [
        2.98716005606,
        2.98133938106,
        2.99259882606,
        2.9854423640600003,
        2.99993590506,
        2.9744325550600004,
        3.0467131320600003,
        2.96774719406,
        3.15988444706,
        2.96983897606
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.68837921786,
      "stddev": 0.01311105811603398,
      "median": 0.6880676295600001,
      "user": 0.36284426000000003,
      "system": 1.33291112,
      "min": 0.66432650006,
      "max": 0.7073632870600001,
      "times": [
        0.68724519506,
        0.7014557140600001,
        0.6798910490600001,
        0.6790667680600001,
        0.7073632870600001,
        0.6888900640600001,
        0.69867098806,
        0.6976860290600001,
        0.66432650006,
        0.67919658406
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.68233963456,
      "stddev": 0.012826080521768786,
      "median": 0.67972263056,
      "user": 0.35648666,
      "system": 1.30093372,
      "min": 0.66566041606,
      "max": 0.7062995900600001,
      "times": [
        0.6888916800600001,
        0.67956709406,
        0.6957779460600001,
        0.67987816706,
        0.7062995900600001,
        0.6886458770600001,
        0.6736918370600001,
        0.6789154930600001,
        0.6660682450600001,
        0.66566041606
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 6.960 ± 0.124 6.742 7.117 1.40 ± 0.04
pacquet@main 6.913 ± 0.189 6.686 7.351 1.39 ± 0.05
pnpr@HEAD 5.030 ± 0.130 4.836 5.273 1.01 ± 0.04
pnpr@main 4.966 ± 0.121 4.812 5.154 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 6.95953857038,
      "stddev": 0.12422953550992917,
      "median": 6.96094832118,
      "user": 4.09414252,
      "system": 3.81877712,
      "min": 6.7423204631800004,
      "max": 7.11655181318,
      "times": [
        7.00599036818,
        6.91149953818,
        7.11655181318,
        7.06761732818,
        7.05408679818,
        7.07512589918,
        6.7423204631800004,
        6.80803237718,
        6.91590627418,
        6.89825484418
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 6.912692728080001,
      "stddev": 0.18923681774253898,
      "median": 6.87235112318,
      "user": 4.117097719999999,
      "system": 3.816914419999999,
      "min": 6.68630813818,
      "max": 7.35120335418,
      "times": [
        6.7657282181800005,
        6.93437559918,
        7.10526233018,
        6.8034008661800005,
        6.88034520218,
        6.87929552318,
        6.8654067231800004,
        6.85560132618,
        7.35120335418,
        6.68630813818
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.02960201648,
      "stddev": 0.12981345720776577,
      "median": 5.04314964818,
      "user": 2.83642432,
      "system": 3.25297472,
      "min": 4.83577668818,
      "max": 5.27285732218,
      "times": [
        5.0400219261800006,
        5.04627737018,
        5.08521225918,
        5.27285732218,
        5.01510183618,
        5.1287148921800005,
        4.9294755201800005,
        5.08216051118,
        4.86042183918,
        4.83577668818
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 4.96600401808,
      "stddev": 0.12104569888949136,
      "median": 4.937430407180001,
      "user": 2.8197785200000003,
      "system": 3.2553950200000004,
      "min": 4.81164813218,
      "max": 5.15352370718,
      "times": [
        4.88758238818,
        5.15352370718,
        4.94260251918,
        4.89283982418,
        5.0796685341800005,
        4.95517028618,
        4.81164813218,
        4.8540368241800005,
        5.15070967018,
        4.9322582951800005
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12778
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,209.64 ms
(-6.74%)Baseline: 4,514.01 ms
5,416.81 ms
(77.71%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,070.85 ms
(+0.34%)Baseline: 3,060.42 ms
3,672.50 ms
(83.62%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,348.64 ms
(-1.14%)Baseline: 1,364.23 ms
1,637.07 ms
(82.38%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,132.80 ms
(-6.04%)Baseline: 4,398.33 ms
5,278.00 ms
(78.30%)
isolated-linker.fresh-restore.cold-cache.cold-store.cold-pnpr📈 view plot
🚷 view threshold
6,959.54 ms
(-2.01%)Baseline: 7,102.28 ms
8,522.73 ms
(81.66%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
664.82 ms
(+5.68%)Baseline: 629.10 ms
754.92 ms
(88.06%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12778
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,226.26 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
688.38 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
675.62 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,164.10 ms
isolated-linker.fresh-restore.cold-cache.cold-store.cold-pnpr📈 view plot
⚠️ NO THRESHOLD
5,029.60 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
713.24 ms
🐰 View full continuous benchmarking report in Bencher

pnpm/rfcs#16 was updated to rename the config surface: every explanation
of a mount began "a mount is a full npm registry at...", so the unit is
now simply a **registry**. `mounts:` becomes `registries:` and
`defaultTarget:` becomes `defaultRegistry:` (npm's own concept), aligning
the server with the client-side `namedRegistries`. pnpr is pre-1.0, so
the old keys are replaced outright with no compatibility mode.

The Rust surface follows the RFC's reference sketch: `MountKind` →
`Registry`, `Mounts` → `Registries`, `MountConfigError` →
`RegistryConfigError`, the `mount` module → `registry`, `Config.mounts`
→ `Config.registries`, and prose/error messages now say "registry"
("name a hosted registry", `/~<name>/`). The axum-level "routes are
mounted" wording is kept — that is framework vocabulary, not the
renamed concept. The vocabulary is now: **registry** — a named surface
pnpr serves; **origin** — the external URL an upstream registry fetches
from; "uplink" remains only as the internal upstream-backend term.
@zkochan zkochan changed the title feat(pnpr): declare patterns on mounts, reduce routers to ordered sources feat(pnpr): declare patterns on registries, reduce routers to ordered sources Jul 3, 2026
Comment thread pnpr/crates/pnpr/src/registry.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2710c1c

@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

🧹 Nitpick comments (1)
pnpr/crates/pnpr/src/registry.rs (1)

262-309: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider unifying the repeated Hosted/Upstream dispatch.

The Hosted/Upstream namespace-check-then-classify logic is duplicated four times: twice in the top-level match (lines 267-280) and twice more in the router loop (lines 287-302). A small helper returning (ConcreteKind, &[PackagePattern]) for a concrete Registry would collapse all four sites to one check.

♻️ Sketch of a possible helper
 impl Registry {
     fn is_concrete(&self) -> bool {
         matches!(self, Registry::Hosted { .. } | Registry::Upstream { .. })
     }
+
+    /// The registry's `(ConcreteKind, patterns)` if it is concrete.
+    fn concrete(&self) -> Option<(ConcreteKind, &[PackagePattern])> {
+        match self {
+            Registry::Hosted { patterns } => Some((ConcreteKind::Hosted, patterns)),
+            Registry::Upstream { patterns } => Some((ConcreteKind::Upstream, patterns)),
+            Registry::Router { .. } => None,
+        }
+    }
 }
🤖 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/src/registry.rs` around lines 262 - 309, The namespace claim
and concrete-kind selection logic in resolve is duplicated across the Hosted,
Upstream, and Router branches. Add a small helper on Registry (or nearby) that
maps a concrete Registry variant to its ConcreteKind and patterns slice, then
use it in resolve to perform the namespace_claims check and return
Resolved::Concrete in one shared path. Keep the Router loop behavior the same,
but have it reuse the helper for each source so the Hosted/Upstream dispatch
lives in one place.
🤖 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 1504-1511: The inline comment in config.rs was mangled by the
mounts-to-registries rename and now contains a nonsensical phrase in the
resolver-only server note. Update that explanatory comment near build_registries
so it clearly states that a resolver-only server skips upstream credential
resolution because it does not expose registry routes, while still building and
validating the registry graph.

---

Nitpick comments:
In `@pnpr/crates/pnpr/src/registry.rs`:
- Around line 262-309: The namespace claim and concrete-kind selection logic in
resolve is duplicated across the Hosted, Upstream, and Router branches. Add a
small helper on Registry (or nearby) that maps a concrete Registry variant to
its ConcreteKind and patterns slice, then use it in resolve to perform the
namespace_claims check and return Resolved::Concrete in one shared path. Keep
the Router loop behavior the same, but have it reuse the helper for each source
so the Hosted/Upstream dispatch lives in one place.
🪄 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: 4177bdd1-58de-4fc9-aaea-31aa34ec175a

📥 Commits

Reviewing files that changed from the base of the PR and between bad1e51 and 2710c1c.

📒 Files selected for processing (16)
  • pnpr/crates/pnpr/config.yaml
  • pnpr/crates/pnpr/src/config.rs
  • pnpr/crates/pnpr/src/config/tests.rs
  • pnpr/crates/pnpr/src/journal.rs
  • pnpr/crates/pnpr/src/lib.rs
  • pnpr/crates/pnpr/src/main.rs
  • pnpr/crates/pnpr/src/registry.rs
  • pnpr/crates/pnpr/src/registry/tests.rs
  • pnpr/crates/pnpr/src/s3.rs
  • pnpr/crates/pnpr/src/search.rs
  • pnpr/crates/pnpr/src/server.rs
  • pnpr/crates/pnpr/src/storage.rs
  • pnpr/crates/pnpr/src/upstream.rs
  • pnpr/crates/pnpr/tests/auth_publish.rs
  • pnpr/crates/pnpr/tests/policy.rs
  • pnpr/crates/pnpr/tests/server.rs
✅ Files skipped from review due to trivial changes (6)
  • pnpr/crates/pnpr/src/search.rs
  • pnpr/crates/pnpr/src/upstream.rs
  • pnpr/crates/pnpr/src/main.rs
  • pnpr/crates/pnpr/src/journal.rs
  • pnpr/crates/pnpr/src/s3.rs
  • pnpr/crates/pnpr/src/storage.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • pnpr/crates/pnpr/tests/auth_publish.rs

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 2710c1c

…ublishes (review)

Two review findings on #12778:

Serving dispatch had a fallback that treated any `Config::uplinks` key as
an addressable upstream when the registry graph did not know the name,
so an embedder-inserted uplink was served at `/~<name>/` without ever
passing through `Registries::resolve` — an unbounded, unvalidated side
door past the namespace model. Server construction (`try_router*`,
`serve`, `serve_listener`) now folds every uplink into the graph as a
pattern-less upstream registry and validates the whole graph
(`Config::ensure_valid_registry_graph`), then the per-request fallback
is gone: the graph is the only dispatch table, a programmatically-built
config fails closed like a YAML load, and an embedder can bound an
uplink by declaring its registry entry (with patterns) before serving.

The off-pattern publish rejection fired before the hosted access mask,
so probing `/~<name>/` with an unclaimed name distinguished a private
hosted registry (400) from an undefined one (404). The loud rejection
is now gated on `registry_visible_to_caller`: hosted registries stay
masked behind their access list (the write path answers exactly like a
read), upstream registries are not masked (their reads already 403),
and a router is visible whenever one of its sources is.
Copilot AI review requested due to automatic review settings July 3, 2026 06:58
@zkochan

zkochan commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Both Qodo findings are fixed in 1b50e94:

  1. Uplink fallback skips namespace — the UnknownRegistryConfig::uplinks fallback in serving dispatch is removed. Server construction (try_router*, serve, serve_listener) now calls Config::ensure_valid_registry_graph(), which folds every uplink into the registry graph as a pattern-less upstream registry and validates the whole graph — so the graph is the only dispatch table, a programmatically-built config fails closed like a YAML load (new test building_the_server_rejects_an_invalid_programmatic_registry_graph), and an embedder can bound an uplink by declaring its own registry entry with patterns: before serving. This keeps the existing embedder contract (pacquet's pnpr-client tests insert uplinks programmatically and expect /~<name>/ to serve them) while removing the unvalidated side door.

  2. Unclaimed publish leaks registry — the off-pattern 400 is now gated on a new registry_visible_to_caller check: hosted registries stay masked behind their access list (a denied caller gets the same 404 an undefined registry gives, matching the read paths), upstream registries stay unmasked (their reads already answer 403), and a router is visible whenever one of its sources is. New test off_pattern_publish_is_masked_for_callers_the_registry_denies pins both halves.

All 571 pnpr tests plus the pacquet pnpr-client/pnpr_install suites pass.


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

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 1b50e94

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

qodo-free-for-open-source-projects Bot commented Jul 3, 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

…y (review)

`PackagePattern::parse` accepted `@<scope>/*` shapes whose scope request
parsing (`PackageName::parse`) rejects — `@.acme/*`, `@../*`, a scope
with a separator or `:` — producing a claim no valid package name can
ever match. A mistyped private-scope claim would then be a dead pattern
that silently lets the names it was meant to cover land on a later
(often public) router source. The scope segment is now validated with
the same rule request parsing applies, and an invalid one is the new
`ScopePatternNotAScope` config error, mirroring `ExactPatternNotAName`.

Also repairs one comment the mechanical mounts-to-registries rename
garbled in config.rs.

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 18 out of 18 changed files in this pull request and generated 1 comment.

Comment thread pnpr/crates/pnpr/src/server.rs Outdated
The cold-pnpr benchmark scenario drives every benchmarked pnpr revision
with one cold-mock config that until now carried two routing shapes:
`mounts:`/`defaultTarget:` for the mount-model servers and the legacy
`uplinks:` + `packages: proxy:` for pre-mount binaries. A pnpr built
from this branch reads `registries:`/`defaultRegistry:` and ignores
both older blocks, so it derived "no registries declared", started
resolver-only, and 404d every install — hyperfine aborted the scenario
at its first benchmark (the CI failure on this PR).

The config now carries all three shapes; every revision ignores the
blocks it does not recognize (no revision sets `deny_unknown_fields` on
its config file), so each one proxies to the same origin. Verified by
launching this branch's pnpr with the tri-shape config: the registry
surface comes up and serves a proxied packument.
Copilot AI review requested due to automatic review settings July 3, 2026 09:36

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 34 out of 34 changed files in this pull request and generated 1 comment.

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8851f26

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 8851f26

…nfigs (review)

`Config::ensure_valid_registry_graph` now mirrors the remaining YAML
upstream invariant: an upstream with no `access:` gate is publicly
reachable at its `/~<name>/` endpoint, so it may not send custom request
headers — any header can carry a credential, and an ungated endpoint
would let every caller spend it (a confused deputy). YAML loads already
reject this shape in `resolve_upstream_registry`; programmatic configs
now fail server construction the same way. The header-forwarding e2e
test gains the access gate (and an authenticated caller) the invariant
demands.

Also renames the `UPLINK_TOKEN` test constant the case-sensitive
uplinks-to-upstreams sweep missed.
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 cf3aa85

… configs (review)

The uplink fold in `Config::ensure_valid_registry_graph` is a no-op when
the name already exists in the graph, so a programmatic config could
leave upstream serving config under a name the graph declares as hosted
or a router — two different origins behind one `/~<name>/` identity,
with the dormant upstream's credential still offered by the resolver.
The fold now requires the existing entry to actually be an upstream, and
the hosted table gets the mirror check (a hosted serving row under a
name the graph declares as a different kind). A hosted row with no graph
entry at all stays allowed — it is merely dormant, nothing routes to it.
YAML loading already rejects these collisions while building the graph.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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

Copilot AI review requested due to automatic review settings July 3, 2026 11:02

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 34 out of 34 changed files in this pull request and generated 4 comments.

Comment thread pnpr/crates/pnpr/src/route/tests.rs Outdated
Comment thread pnpr/crates/pnpr/src/config.rs Outdated
Comment thread pnpr/crates/pnpr/src/config.rs Outdated
Comment thread pacquet/tasks/registry-mock/src/pnpr_command.rs Outdated
Comment thread pnpr/crates/pnpr/src/route.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 934f28a

…nfig

The `REGISTRY_MOCK_LOCAL_PATTERNS` and `Config::proxy` docs claimed the
programmatic pattern set is kept in sync with the bundled `config.yaml`
`local` registry, but the YAML additionally claims the exact names the
TS test suite publishes — names pacquet's in-process registry never
sees. Both docs now say the sync target is the fixture subset. Also
updates the registry-mock launcher comment describing what the bundled
config serves, and fixes an "a upstream" article the rename left
behind.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 934f28a

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 7fa37d4

@zkochan zkochan merged commit 02ef9b5 into main Jul 3, 2026
36 checks passed
@zkochan zkochan deleted the improve-pnpr-api branch July 3, 2026 11:38
Comment on lines +1520 to +1526
let mut upstreams: IndexMap<String, UpstreamConfig> = IndexMap::new();
let (hosted, registries) = build_registries(
&mut upstreams,
file.registries,
file.default_registry,
registry.enabled,
)?;

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. Disable-registry skips upstream auth 🐞 Bug ☼ Reliability

Config::from_yaml_str_with_overrides passes registry.enabled into build_registries as
resolve_upstreams, so --disable-registry leaves Config.upstreams empty even when the resolver is
enabled. The resolver’s RouteHook ignores client-forwarded auth headers and derives server-managed
credentials from Config.upstreams, so private/proxied resolutions will fail (and won’t be
routable/cached correctly).
Agent Prompt
### Issue description
`--disable-registry` is intended to disable serving the npm-registry HTTP surface, but it currently also prevents upstream registry credentials/config from being resolved into `Config.upstreams`. The resolver still installs a route hook that ignores client-forwarded auth and relies on `Config.upstreams` to select server-managed credentials for proxied routes, so private dependency resolution breaks.

### Issue Context
- `Config::from_yaml_str_with_overrides` uses `registry.enabled` to decide whether to resolve upstream registries into `Config.upstreams`.
- `RouteContext::from_config` builds its proxied-alias table from `config.upstreams`.
- The resolver attaches a route hook (`RouteHook`) to `AuthHeaders`, and pacquet’s `AuthHeaders` contract is: when a route hook is present, client credentials are ignored.

### Fix Focus Areas
- pnpr/crates/pnpr/src/config.rs[1502-1526]
- pnpr/crates/pnpr/src/config.rs[1724-1791]

### Proposed fix
- Decouple “serve registry routes” from “resolve upstream configs needed by the resolver’s route hook”.
- Pass `resolve_upstreams = registry.enabled || resolver.enabled` (or an equivalent condition) into `build_registries`, so upstream credentials are resolved whenever the resolver is enabled.
- Optionally update related comments/docs so they don’t claim a resolver-only tier ‘never uses’ upstream secrets, since the route hook can require them.
- Consider updating `ensure_valid_registry_graph` invariants if they currently only require upstream backing config when `registry.enabled` is true, but resolver behavior requires them too.

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

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 7fa37d4

KSXGitHub pushed a commit that referenced this pull request Jul 3, 2026
…rams-auz8x1

Integrate the pnpr registry-declaration refactor from main (#12778):
mount -> registry, Uplink* -> Upstream*, and the server/route/resolver rewrite.
Conflicts in the pnpr sources and the pnpr-client/pnpr integration tests were
resolved in favor of main's new structure.

Re-ran perfectionist::needless_borrowed_parameters over the merged tree and
fixed the findings in main's new code the same way as the rest of the branch:
production functions (upstream_cache_digest, ResolvedAlias::from_upstream,
tarball_integrity_error, Upstream::new) take their String argument by value,
with move-or-clone call sites; test fixtures widen to impl Into<String>, or to
impl AsRef<str> / impl AsRef<Path> where the helper has a large borrowed-arg
fan-out, satisfying the lint without call-site churn.

Verified on the merge: cargo dylint (0 findings), cargo clippy --all-targets
-D warnings, RUSTDOCFLAGS=-D warnings cargo doc, cargo fmt --check, taplo
format --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnR7HFwhaS9Y5NH56X3MCD
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

Labels

product: pnpr reviewed: coderabbit CodeRabbit submitted an approving review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants