Files
pnpm/pnpr
Zoltan Kochan 90dd34672b feat(pnpr): merge the namespace and the ACL into per-registry packages: maps (RFC pnpm/rfcs#17) (#12787)
* feat(pnpr): merge the namespace and the ACL into per-registry packages: maps

Implements the revised model from pnpm/rfcs#17, replacing the interim
patterns:-plus-global-ACL shape outright (pre-1.0, no compatibility
mode).

Every concrete registry now declares one packages: map whose keys are
its namespace (the former patterns: list) and whose values are the
per-package access/publish/unpublish rules (the former top-level
packages: block, scoped to the one registry that serves the name). One
declaration routes, filters, and authorizes. The registry-level access:
is the default an entry's omitted fields fall back to; publish defaults
to $authenticated and unpublish to nobody.

Selection is by specificity, not key order: an exact name beats
@scope/* beats @*/* beats **. YAML mappings are formally unordered, so
a formatter or yq round-trip must not change which rule applies; the
restricted pattern language makes the winner unique (at most one
matching key per tier), no entry can be dead, and a duplicate key is
the only within-registry error. Router sources: stay an ordered list
with their unreachable/shadowed-claim validation intact.

The removed top-level packages: block is a startup error naming the
per-registry replacement — it used to enforce access, so dropping it
like an unknown verdaccio key would silently open previously gated
packages on upgrade.

public: true keeps meaning the upstream fetch (no credential, no
headers, no registry-level access default), but per-package access
rules are now permitted on a public upstream — they gate who may read
the name through pnpr, not how pnpr fetches it. publish/unpublish
values on any upstream are rejected: no write can land there.

Hosted denials answer by tier, preserving both prior behaviors: a
caller the registry-level default denies is masked with 404 for every
name (a blanket-private registry never reveals which names exist, even
explicitly ruled ones), while an explicit entry denying a caller the
registry itself admits rejects loudly — 401 anonymous / 403
authenticated — so clients can prompt for credentials (the
registry-mock needs-auth contract).

The resolver's route classification resolves path-less fetches through
the registry graph — the same dispatch serving uses — and hosted
private-access descriptors are registry-qualified (registry NUL
package), so the same name@version on two hosted registries can never
share a cache key; unqualified descriptors from older builds fail
closed and re-resolve.

The bundled config.yaml moves the fixture namespace and the old ACL
into the local registry's packages: map (YAML anchors keep it
readable), and Config::proxy / Config::static_serve carry the
registry-mock rules programmatically.

Implements the pnpr side of pnpm/rfcs#17 only; no client or lockfile
changes.

* fixup: review + CI — missed consumer, indexed rule lookup, lint/typos

- pacquet-pnpr-client's integration test builds an UpstreamConfig
  literal; add the new rules field (this compile error was failing the
  CI test jobs and coverage).
- Index PackageRules::for_package by specificity tier (exact/scope
  maps + any-scoped/all slots) instead of scanning every rule: the
  lookup runs on every read, write, search hit, and route
  classification, and the tier chain makes the winner a map lookup.
- Sharpen the hosted_gate and classify_hosted docs: the effective
  per-package access decides — an explicit entry fully decides its
  names, including opening one name on an otherwise-private registry —
  and only the denial's *shape* varies by tier (default denial masks,
  explicit-entry denial is loud). Classification admits with the same
  lookup serving does, so the two cannot diverge.
- Appease dylint (single-letter closure params, intra-doc link),
  clippy (trailing comma), and typos (mis-order).

* fixup: gate alias selection by upstream per-package rules; search fast path

Route classification now consults the upstream registry's packages:
rules per name: a caller the effective access denies is never handed
the server-owned credential, so a fresh resolve fails closed exactly
where the serving endpoint would deny the read. Cache replay stays
registry-scoped by design (the alias descriptor names no package) —
documented at the descriptor gate.

Search skips a hosted registry outright when no rule of it could admit
the caller, restoring the pre-merge fast path: a blanket mask must not
become an enumeration or scan-timing primitive.

* fixup: drop the pre-mount shape from the benchmark cold-mock config

The cold-mock config carried three routing shapes so one file could
drive any benchmarked pnpr revision, on the premise that every server
ignores the blocks it doesn't recognize. HEAD broke that premise on
purpose: a top-level packages: block is now a startup error, so the
pnpr@HEAD revision mock refused to start and the benchmark job failed.

Keep the two shapes that still coexist (registries:/defaultRegistry:
and mounts:/defaultTarget:) and document that a pre-mount pnpr can no
longer share a config file with current ones.

* fixup: reject a bare top-level packages: key too

Option<IgnoredAny> maps a present-but-null packages: (a bare key, or
~) to None, slipping past the loud rejection. Detect presence through
a custom deserializer that consumes any value — including null — so
every spelling of the removed key fails startup identically.

* fixup: package-qualified alias descriptors for explicitly refined names

Cache replay was registry-scoped for alias descriptors, so a caller
passing the registry-level gate but denied by a per-package upstream
access refinement could replay a cached resolution a fresh resolve
would refuse them. The alias descriptor now carries the package name —
only when the upstream's rules explicitly refine that name's access —
and replay re-checks the refinement through the same per-package-aware
alias selection a fresh resolve uses. Unrefined names keep the plain
registry-scoped descriptor, so the common footprint stays one
descriptor per alias. The refined metadata mirror namespaces per
package for the same reason.

Also document why resolves_to_private_source treats every name on an
access-bearing upstream as caller-gated: unlike hosted registries, the
upstream registry-level gate is enforced independently at serving
(authorized_upstream runs before per-package rules), so a per-package
'access: $all' entry cannot open a name on a private upstream.
2026-07-03 21:49:06 +02:00
..