pnpm can now use different auth tokens for different package scopes, even when those scopes use the same registry URL.
Previously, auth was selected only by registry URL. If `@org-a` and `@org-b` both used `https://npm.pkg.github.com/`, they had to share the same token. This caused problems for registries that issue tokens per organization or per scope.
Configure a scope-specific token by adding the package scope after the registry URL in the auth key:
```ini
@org-a:registry=https://npm.pkg.github.com/
@org-b:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:@org-a:_authToken=${ORG_A_TOKEN}
//npm.pkg.github.com/:@org-b:_authToken=${ORG_B_TOKEN}
//npm.pkg.github.com/:_authToken=${FALLBACK_TOKEN}
```
`pnpm login --registry=https://npm.pkg.github.com --scope=@org-a` writes the token to the same scope-specific auth key.
When installing or publishing `@org-a/*`, pnpm uses `ORG_A_TOKEN`. For `@org-b/*`, pnpm uses `ORG_B_TOKEN`. Packages without a matching scope continue to use the registry-wide fallback token.
* fix(pnpr): parse /v1/resolve NDJSON stream in the TypeScript client
The pnpr server streams the /v1/resolve response as application/x-ndjson
(one package frame per resolved tarball, then a terminal done/error/
violations frame), but the TypeScript client still parsed the whole body
as a single JSON object, failing with "Unexpected non-whitespace
character after JSON". Parse the NDJSON frames and act on the terminal
frame instead.
Closes#12234 follow-up.
* fix(pnpr): reject unknown /v1/resolve frame types in the client
Fail fast with a protocol error when the NDJSON stream carries a frame
whose type is neither package nor a known terminal frame, instead of
returning it and surfacing a confusing lockfile error downstream.
## Summary
Reworks pnpr from an install/file accelerator into a resolve-only accelerator:
- `POST /v1/resolve` resolves against the client-supplied registries and returns a gzipped JSON lockfile response
- pacquet/pnpm clients then fetch tarballs normally from registries with their own credentials and existing parallel fetch/integrity paths
- pnpr no longer serves package file bytes or store-index rows, so the server-side file diff, file-frame response, grant table, and public-package byte-gating code are removed
The follow-up resolution fast paths are included on the new measured path:
- repeated public no-lockfile resolves use a bounded in-memory TTL cache
- fresh frozen input lockfiles skip the server-side lockfile-only pacquet resolve after verification proves the lockfile is usable
- input lockfile verification and the verdict cache are preserved
## Benchmark
Integrated benchmark on Linux shows small improvements in all pnpr rows, with the clearest movement in hot restore. This should be treated as an incremental win rather than a large install-speed change.
| Scenario | `pnpr@HEAD` | `pnpr@main` | Change |
| --- | ---: | ---: | ---: |
| fresh restore, cold cache + cold store | `1.677 s ± 0.090` | `1.686 s ± 0.070` | ~0.6% faster |
| fresh restore, hot cache + hot store | `492.5 ms ± 18.1` | `521.9 ms ± 33.4` | ~5.6% faster |
| fresh install, cold cache + cold store | `1.997 s ± 0.025` | `2.003 s ± 0.038` | ~0.3% faster |
| fresh install, hot cache + hot store | `1.211 s ± 0.024` | `1.236 s ± 0.038` | ~2.0% faster |
## Trade-off
Going registry-direct means pnpr no longer gates tarball bytes itself. Private package access is enforced by the upstream registry when the client fetches tarballs. Resolution policy still runs server-side: lockfile verification, release-age policy, trust policy, and resolved package selection continue to happen before the client fetches bytes.
Closes#12184 (part 2).
#12181 shipped the per-caller access gate on `POST /v1/install`, which authorizes every served package against pnpr's own `packages:` policy — the complete answer **while pnpr fetches anonymously**. This PR adds the remaining piece: forwarding the caller's per-registry credentials so the accelerator can resolve/fetch **external private** content as the caller, and gating that content per user against the registry that actually owns it.
## Credential forwarding (issue steps 1–2)
- **Wire:** `POST /v1/install` gains an `authHeaders` body map (`{ "//host/path/": "Bearer …" }`, the shape `AuthHeaders::from_map` consumes / `getAuthHeadersFromCreds` produces) plus an HTTP `Authorization` header. The body map carries the *upstream* registry tokens; the header identifies the caller to pnpr's own gate and keys the grant table.
- **pacquet plumbing:** a request-scoped `Arc<AuthHeaders>` is threaded via a new `Install.auth_override` field and an `auth_override` param on `build_resolution_verifiers`, so resolution/verification run as the caller **without** baking per-user auth into the interned `&'static Config` (which would leak one config per user).
- **Server:** `handle_install` builds the per-request `AuthHeaders` and threads it through resolve, verify, and `fetch_uncached` (which now returns the freshly-fetched set).
- **Clients:** pacquet `pnpr-client` and `@pnpm/pnpr.client` send `registry` / `namedRegistries` / `authHeaders` + `Authorization`; the TS path sources them from the caller's registry credentials via `@pnpm/network.auth-header` (`getAuthHeadersFromCreds` is newly re-exported). `@pnpm/worker` is unchanged — downloads happen server-side.
- **Credential scope:** both clients forward the caller's *full* credential map, not a subset scoped to the declared registries. The registries a dependency graph touches aren't knowable up front — a transitive package can be scope-routed to another registry or pinned to a tarball URL on a host that's in `.npmrc` but isn't a declared registry — so pnpr attaches the right token per fetched URL exactly as a local install does. These are package-fetch credentials going to the very service the caller configured to fetch its packages.
## Per-user grant table (issue steps 3–4)
Externally-resolved private content carries no pnpr policy, so the store's possession of the bytes must not authorize a user the upstream never cleared. A served package is dispatched by **whether a forwarded credential was used to fetch it**:
- **No forwarded cred → pnpr-as-authority:** the existing local `packages:` policy check, unchanged.
- **Forwarded cred → upstream-as-authority:** gated against a persistent `(user, name@version)` grant table (SQLite, modeled on `VerdictCache`). Freshly fetched this request ⇒ record + allow (the upstream just accepted the token). Cache hit with a standing grant ⇒ allow, no upstream trip. Cache hit, no grant ⇒ re-verify against the owning registry with the caller's credential — record on success; **clear-on-discovery** (purge the user's grants for the package) + deny on `401`/`403`. TTL is the `installAccelerator.grantTtl` config knob (default: permanent).
## Public vs private (no per-user gating for public packages)
A forwarded credential matching a registry doesn't mean a package is *private* — in a mixed proxy (one registry serving a company's private packages **and** public ones), the token matches everything, and gating public content per user would cost a grant row and a re-verify round trip per user for bytes anyone may read. So before the per-user path, a not-yet-classified cache hit is probed **anonymously**: a `2xx` classifies the package public in a global set (no user pays for it again, no grant, no further round trip); a `401`/`403` means it's genuinely private and falls through to the grant / re-verify path above. Public packages thus cost **one anonymous probe across the whole fleet**, not one per user.
## Tests
- pnpr: grant-table + public-set mechanics, regime dispatch, the upstream-authorization paths (fresh-fetch, granted cache hit, private re-verify-and-record, denied-clears-grants, public-classified-once-then-free), and forwarded-cred-routes-around-local-policy.
- pacquet `pnpr-client`: a test asserting `authHeaders` + `Authorization` travel on the wire.
- Full suites green: `pnpr` (237), `pacquet-package-manager` (389), `pacquet-pnpr-client` (12), `pacquet-network`/`config` (325); clippy `-D warnings`, `cargo fmt`, rustdoc `-D warnings --document-private-items`, `typos`, and the TS compile all clean.
## Scoped follow-ups (not in this PR)
- Clear-on-discovery fires at the re-verify hook only. A `401`/`403` during the cold resolve aborts the request anyway (nothing is served); threading the offending package out of the deep resolve error to also clear stale grants for *future* requests needs structured auth errors.
- Per-scope external registries route via the default registry, since pacquet doesn't yet surface `@scope:registry` routing in `collect_packages`.
* fix(pnpr): authorize served packages against pnpr's policy in /v1/install
A content-addressed digest in the install-accelerator store is shared
across packages and says nothing about access, so the store's possession
of a package's bytes is not a capability to receive them. `/v1/install`
served files for any package found in the store, including ones reached
only on the cache-hit / frozen-lockfile path where no access check
happened — letting a caller who knows a private package's digest pull
bytes the registry routes would 401 on.
Check every served package against pnpr's own `packages:` policy before
serving — the same decision `serve_packument` / `serve_tarball` make, in
process, with no network round trip (so a warm shared server keeps its
resolution advantage). `serve_install` resolves the caller's identity
from `Authorization`; `deny_unauthorized_packages` denies the install
(401 anonymous / 403 authenticated-but-outside-the-allowed-set) when any
served package is not readable by the caller.
This authorizes against pnpr's own surface, the authority for everything
the store can hold today (pnpr fetches anonymously, so cached content is
pnpr-hosted or publicly fetchable). When credential forwarding lands,
packages the client resolved from external registries under its own token
carry no pnpr policy and will need per-caller re-verification against the
owning registry (TTL-cached) — noted at the check and tracked in #12184.
The raw `/v1/files` endpoint is still unauthenticated; removing it (it is
superseded by the inline single-response path) is a follow-up (#12184)
that also ports the TS `@pnpm/pnpr.client` + worker off the two-trip path.
---
Written by an agent (Claude Code, claude-opus-4-8).
* fix(pnpr): remove the unauthenticated /v1/files endpoint
`POST /v1/files` served any CAFS file by digest with no authentication
and no package identity, so the access gate on `/v1/install` (which is
per package) couldn't cover it — it had to be removed, not gated. It was
already superseded by the single-response inline path (#12178).
* Server: `/v1/install` always answers with the inline gzipped body
(lockfile + stats + store-index entries + the missing files' contents);
the NDJSON two-trip path, the `/v1/files` route, `handle_files`, and the
`FilesRequest`/`is_valid_sha512_hex` helpers are gone.
* TS client + worker: `@pnpm/pnpr.client` now does the one inline request
and hands the file frames to `@pnpm/worker`'s `writeCafsFiles`, which
writes them to the CAFS; the `fetchAndWriteCafsFiles` /v1/files fetcher
is replaced. Error bodies are decompressed before being surfaced, since
the server also gzips its JSON error responses (e.g. an access denial).
Verified end to end by `pnpm/test/install/pnpmRegistry.ts` (11 tests:
install / add / remove / workspace through a real pnpr server).
Closes the second half of the install-accelerator access work (#12184);
file-bearing responses are now both inline-only and access-gated.
* refactor: rename the agent client + setting to pnpr
The pnpm-side client and its config setting still carried the old
"agent" name after the server moved to pnpr. Align both with pnpr (and
with pacquet, which already uses `pnprServer`):
- Move `agent/client` → `pnpr/client` and rename the package
`@pnpm/agent.client` → `@pnpm/pnpr.client` (exported `AgentProject`
type → `PnprProject`).
- Rename the config setting `agent` → `pnprServer` (`--pnpr-server`
CLI flag), matching pacquet's setting name.
- Rename the internal install-path symbols and the user-facing log /
error strings that mentioned "pnpm agent" to "pnpr".
No behavioral change — only names. The e2e suite now drives
`--config.pnprServer`.
* fix: forward optionalDependencies to the pnpr server
`PnprProject` and the install-request body only carried `dependencies`
and `devDependencies`, so a project's `optionalDependencies` were
dropped on the way to the pnpr server — it resolved as if they didn't
exist, producing a different lockfile than the local resolver.
Thread `optionalDependencies` through the client request shape, the
deps-installer single-project and workspace request builders, and the
pnpr server (`InstallRequestProject` / `InstallRequest` + the throwaway
manifest it writes for resolution). Adds an e2e case asserting an
optional dependency is resolved through `pnprServer`.