A hosted registry that declares `tarballs: tar` is only ever served through tar registries, so the published `.tgz` has no reader there. Publish now verifies the `.tgz`, records its tar hash as before, and stores the tar compressed with zstd at level 19, within the window pnpm decodes, as `<name>-<version>.tar.zst` in place of the `.tgz`. A `.tgz` that does not decode in full fails the publish with a 400. The version route serves the stored bytes to a zstd client and the tar decoded to any other, which the compression layer gzips when asked. These versions skip the per-replica level-19 variant cache. Config loading rejects a router without `tarballs: tar` that lists such a registry, since it would have no `.tgz` to serve. Unpublish and packument rewrites remove the stored tar, `pnpr verify` counts it as the version's blob instead of an orphan, and `pnpr export` writes it. Decided in pnpm/tasks#226.
42 KiB
id, title
| id | title |
|---|---|
| configuration | Configuration |
pnpr uses a YAML config. When you don't pass -c, pnpr looks for a global
config.yaml in its config directory and otherwise falls back to a minimal
production default (which proxies public npmjs packages and serves locally
hosted packages).
A minimal config that hosts a private scope locally and routes everything else to the public npm registry:
storage: ./storage
auth:
htpasswd:
file: ./htpasswd
registries:
local:
type: hosted
packages:
# The names this registry serves, plus their access rules.
'@mycompany/*':
access: $authenticated
publish: $authenticated
npmjs:
type: upstream
url: https://registry.npmjs.org/
public: true
main:
type: router
sources: [local, npmjs]
defaultRegistry: main
Pass it with -c, and create the first account with
pnpr add-user:
pnpr -c ./pnpr.yaml add-user alice
pnpr -c ./pnpr.yaml
Relative paths in the config, such as storage, cache,
auth.htpasswd.file, auth.tokens.file, osv.path, and ui.dir, resolve
against the config file's directory. For the bundled config, they resolve
against the working directory. With --storage, the bundled config keeps
htpasswd and tokens.db in the storage directory.
storage and cache
pnpr keeps two kinds of data:
storage: the source of truth. It holds packages published to this server plus anything served in static mode. Back this up and keep it on a durable volume.cache: the disposable mirror of upstream registries plus the resolver store, resolver cache, lockfile-verdict cache, local shared-artifact store, and S3 upload staging scratch. Safe to wipe at any time. Defaults to a.pnpr-cachesubdirectory ofstorage; point it at a separate, ephemeral volume to keep cached upstream content off the durable disk.
storage: ./storage
# Path string:
#cache: ./cache
# Or settings table with budget and retention policies:
#cache:
# path: ./cache # defaults to .pnpr-cache in storage
# maxSize: 20GB # enforces LRU eviction when total size exceeds this
# maxAge: 30d # TTL deletion of cached entries older than this
When maxSize or maxAge is configured, pnpr evicts stale entries on startup
and runs periodic eviction in the background. On startup, pnpr also prunes
orphaned private upstream namespaces left behind by previous process runs using
ephemeral per-process secrets. Use pnpr cache-gc for
on-demand or offline reclamation.
The hosted store can instead live in an S3-compatible object store. See Storage backends.
registries and defaultRegistry
The registries: map is pnpr's only routing surface. Every addressable
registry origin is a named registry, exposed at https://<pnpr>/~<name>/. There
are three kinds:
hosted: a registry pnpr itself is the authoritative origin for. The only kind that accepts writes (publish, dist-tags, unpublish).upstream: exactly one external registry origin, proxied and cached.router: an ordered list of concrete source registries; a package resolves to the first listed source that claims its name.
The model is governed by one invariant: provenance is declared, never
inferred. Every concrete registry declares the package names it serves (its
namespace) through its packages: map, a package
resolves to exactly one declared origin, and no configuration can express a
cross-origin fall-through, not on "not found" and not on "unavailable". The
namespace is enforced at the registry, on every path to it: a read of a name
outside the namespace is a definitive 404 answered before storage or the
upstream is consulted, and an off-namespace publish is rejected. This closes
the dependency-confusion class of attacks by construction.
defaultRegistry names the registry that an ecosystem's base URL, such as
https://<pnpr>/npm/, aliases. This is usually a router. When omitted, the base serves
no registry and clients must address a /~<name>/ URL under it.
A registry name is served as the single URL path segment /~<name>/, so it
must be one URL-safe segment: it cannot be empty, . or .., start with .,
or contain /, \, :, %, ?, #, whitespace, or control characters. The
~ must arrive unencoded: %7E does not select the named registry.
A registry also declares which package ecosystem it
serves, through ecosystem: (npm, cargo, pypi, or oci; npm by default).
Each ecosystem is addressed through its own path prefix (/npm/, /cargo/,
/pypi/, or /oci/), and registry names can be reused across ecosystems
by grouping the entries under an ecosystem key. See
Cargo and Python registries and
Container images.
The packages map
Every concrete registry (hosted or upstream) takes an optional packages:
map. Its keys are the registry's namespace (the package names it serves),
and its values are the per-package access rules. One declaration routes,
filters, and authorizes. When the map is omitted, the registry serves every
name.
Keys use a deliberately small pattern language, so coverage can be checked statically:
| Pattern | Matches |
|---|---|
** |
Every package name. |
@*/* |
Every scoped package, any scope. |
@scope/* |
Every package in one scope. |
foo, @scope/foo |
Exactly that package. |
Any other wildcard is a config error rather than a silently-never-matching literal.
The most specific matching key wins: an exact name beats @scope/*, which
beats @*/*, which beats **. Key order carries no meaning. At most one key
per specificity tier can match a name, so the winning rule is unique and
reordering the map (by a formatter, or a YAML round-trip) can never change
which rule applies. A duplicate key is a config error.
Each value can set access, publish, and unpublish. When a field is
omitted, access falls back to the registry-level access: default, publish
defaults to $authenticated, and unpublish defaults to nobody.
Cargo and PyPI registries reject unpublish rules because those ecosystems do
not support package unpublishing.
Every one of those fields takes either a single access token or a YAML list of them, and the grammar is deliberately small:
| Token | Admits |
|---|---|
$all |
Anyone, authenticated or not. |
$authenticated |
Any logged-in user. |
$anonymous |
Callers with no credentials. |
team:<name> |
The members of a team declared on this registry. |
alice |
Exactly that username. Any bare token is a username. |
Tokens are validated at config load and every near-miss is rejected loudly, rather than being read as a username that happens to admit nobody:
$is reserved for the three built-ins. Any other$tokenis an error.all,authenticated,anonymous, and@-prefixed spellings like@allare errors suggesting the$form.team:is the only typed prefix; any other<kind>:prefix is an error. (htpasswd forbids:in usernames, which is what frees the character.)team:<name>must resolve to a team the same registry declares. An undeclared name is a config error, so a typo can't silently grant to nobody.- One entry is one token: an entry containing whitespace is an error, so write
[alice, bob]rather thanalice bob. - The empty string is an error. To admit nobody, write
[]; to take the default, omit the field.
Hosted registries
registries:
private:
type: hosted
org: mycompany
access: $authenticated
packages:
'@mycompany/*':
publish: $authenticated
unpublish: $authenticated
# An explicit entry fully decides its names — this opens one
# package on an otherwise-private registry.
'@mycompany/open-sdk':
access: $all
| Key | Description |
|---|---|
org |
Storage namespace for this registry's packages, so two hosted registries can hold the same name@version without colliding. Omitted means the default namespace, or <name> for a registry declared under an ecosystem group. Must be a single path-safe segment. |
access |
The default read access for packages: entries that don't set their own. Defaults to $all. This is the only access field settable registry-wide. publish and unpublish belong to packages: entries. |
teams |
Named user sets for this registry, referenced from its access lists as team:<name>. See Teams. |
packages |
The registry's namespace and per-package rules. See above. Only these names can be served from or published to this registry. |
tarballs |
The format this registry serves tarballs in: tgz (default) or tar. See Tar registries. |
Two hosted registries of the same ecosystem cannot share an org namespace.
That's rejected at config load. Hosted registries of different ecosystems can
share an org because each ecosystem uses its own <ecosystem>/ directory
inside the namespace.
The shape of a denial depends on who denied it. A caller that the
registry-level access: default also denies is masked with a 404. A
blanket-private registry never confirms which package names exist. A caller
denied by an explicit packages: entry while the default would admit them is
rejected loudly (401 for anonymous callers, 403 for authenticated ones),
so clients can prompt for credentials.
Upstream registries
An upstream registry is one external origin: one URL, one credential, one
cache namespace. It is either public (fetched anonymously, no credential)
or private (carries a server-owned credential and an access: policy naming
who may use it):
registries:
npmjs:
type: upstream
url: https://registry.npmjs.org/
public: true
corp:
type: upstream
url: https://npm.corp.example.com/
auth:
type: bearer
tokenEnv: CORP_NPM_TOKEN
access: $authenticated
packages:
'@corp/*': {}
| Key | Description |
|---|---|
url |
Upstream registry URL. |
public |
Marks an anonymous, world-readable origin (e.g. the public npm registry). A public upstream sends no credential and no custom headers; declaring auth, access, or headers alongside public: true is a config error. |
auth |
Server-owned credential for a private origin. type is bearer or basic; provide token, or tokenEnv: true to read NPM_TOKEN, or tokenEnv: NAME to read a named environment variable. |
headers |
Extra request headers to send upstream. If headers.Authorization is set, it overrides the auth-derived header. |
access |
Which pnpr users may reach this registry at /~<name>/ (and resolve through it). Required for a non-public upstream. |
teams |
Named user sets for this registry, referenced from its access lists as team:<name>. See Teams. |
packages |
The names this upstream serves through pnpr. See above. |
maxAge |
Per-registry packument freshness window. Overrides packumentTtl and --packument-ttl for this registry. |
timeout |
Per-request upstream deadline. Defaults to 30s. |
maxFails |
Consecutive failures before the upstream circuit breaker opens. Defaults to 2; 0 disables the breaker. |
failTimeout |
Cooldown before an open circuit is probed again. Defaults to 5m. |
tarballs |
The format this registry serves tarballs in: tgz (default) or tar. See Tar registries. |
search |
Opts this upstream into browser-facing search and organization discovery. Defaults to false. See Browsing and discovery. |
cache |
Whether tarballs fetched from this registry are cached locally. Defaults to true; false streams verified tarballs through a temporary file. With caching on, pnpr computes a SHA-512 dist.integrity for up to 64 versions per fetched packument that omit one, caching each tarball it hashes. |
Interval values accept strings such as 30s, 5m, 1h30m, or a bare number
of seconds.
Two upstream-specific rules apply to the packages: map. Per-package access
values are allowed even on a public: true upstream. public describes the
fetch (anonymous, credential-free), while a rule's access gates who may
read the name through pnpr. And publish/unpublish values are rejected on
any upstream: no write can land there.
A private upstream registry is more than a proxy: it is a pnpr-managed
credential for that origin. One upstream token, held by the server, is
fanned out to a whole team through the registry's access: policy. Clients
authenticate to pnpr with their own tokens and never see the upstream
credential. Declaring a packages: namespace on a private upstream also
bounds what that credential can be used for: a caller can't pull arbitrary
public names through it. pnpr never forwards a client's credentials upstream,
and the server-owned credential is only ever attached over the upstream's own
scheme (an https:// registry's token is never sent over plain http://).
Each upstream gets its own cache namespace: a public upstream uses a stable,
secret-free namespace shared across restarts, while a private upstream's cache
is keyed by an HMAC of the registry and its credential, so rotating the
upstream token automatically moves to a fresh namespace, and a private
registry's content can never be served on a public path or through another
registry.
Routers
A router is an ordered sources: list of concrete registries. A package
resolves to the first listed source whose packages: keys claim its name,
authoritatively. Later sources are never consulted, and a name that no source
claims is a definitive 404:
registries:
main:
type: router
sources: [private, corp, npmjs]
A source with no packages: map claims every name (the catch-all) and must
be listed last. A router can order competing claims, but it can never assign a
name to a registry that doesn't claim it: the namespace lives on the concrete
registry, and is enforced there on every path.
Because source order is load-bearing (a misordered router is the one way a mistake could send a private scope to a public origin), pnpr refuses to start on an invalid router:
- a source that is unreachable: everything it claims is already covered by earlier sources (including a catch-all that isn't last);
- two sources claiming an identical pattern (genuinely ambiguous provenance, rejected in either order);
- a duplicate source;
- a source that is undefined, the router itself, or another router (sources must be hosted or upstream registries; no nesting, no cycles);
- a router with no sources.
A router also takes tarballs.
Tar registries
A registry with tarballs: tar serves each version as an uncompressed tar, the gzip-decoded content of its .tgz, with HTTP content coding the client's Accept-Encoding allows. Its metadata describes the tars: dist.integrity is the hash of the tar, and dist.tarball, dist.shasum, and dist.signatures are left out. A version with registry revisions keeps its dist.tarball on the <name>@<version>+r<revision> route, its dist.revision, and its dist.revisions history, because a revision is always served as its uncompressed tar. A tool that verifies npm signatures through its registry, such as pnpm/setup, needs a .tgz registry.
To offer tars next to .tgz files, add a router over the same sources:
registries:
local:
type: hosted
packages:
'@acme/*':
publish: $authenticated
npmjs:
type: upstream
url: https://registry.npmjs.org/
public: true
main:
type: router
sources: [local, npmjs]
zstd:
type: router
sources: [local, npmjs]
tarballs: tar
defaultRegistry: main
npm, Yarn, and pnpm projects that do not opt in keep using main. A pnpm project opts in by keying https://<pnpr>/npm/~zstd/ with serverType: pnpr in its registries.
- Every registry serves a version's tarball at
<registry>/<name>@<version>. A tar registry serves no.tgzpath. - For an upstream package without fresh cached metadata, pnpr reads the version's metadata from
<upstream>/<name>/<version>. If the upstream does not serve it, pnpr reads the package's full metadata. - A hosted registry that declares
tarballs: tarkeeps no.tgz. At publish, pnpr compresses the tar at level 19 and stores only that. Azstdclient gets the stored bytes, and any other client gets the tar decoded, compressed as the request accepts. pnpr refuses to start when a router withouttarballs: tarlists such a registry as a source, because the router would have no.tgzto serve. npm signatures and provenance sign the.tgz, so these packages have neither. - For every other registry, pnpr keeps the published
.tgzand derives the tar from it:- A
gzipclient gets the.tgzitself, which is the gzip encoding of its tar. - A
zstdclient gets a level-19 encoding once pnpr has stored one. Until then pnpr compresses the tar at level 3 as it streams, and a background worker stores the level-19 encoding. Tars larger than 256 MiB stay at level 3. The level-19 encodings live in the cache, so they can be evicted and are made again. - Any other client gets the tar uncompressed.
- A
- A hosted registry learns a tar's hash at publish. An upstream registry learns it the first time it serves the tar with
zstdor no compression, after verifying the upstream.tgz. Until then the version's metadata carries nodist.integrity, and pnpm hashes the tar it downloads. - Tar hashes are kept in the hosted storage under
.tar-integrity/, which every replica shares. - pnpr decodes at most 4.25 GiB from one
.tgz, the most pnpm accepts by default. A version past that has no tar. Copies of tars waiting for the level-19 encoding share a 1 GiB quota, and a request makes no copy when it is full or when another request is already copying that version. tarballsis accepted only on npm registries.
You can also set tarballs: tar on the main router, so the base every pnpm project already uses serves tars. That works when all your clients are pnpm 12.15 or newer and every project declares the base with serverType: pnpr in its registries. Without that declaration, pnpm fails with "names no tarball URL". npm, Yarn, and older pnpm cannot install from such a base: it has no .tgz path, and its metadata has no tarball URL, except for versions with registry revisions. A project whose lockfile was written against the .tgz base has .tgz hashes in it, so run pnpm install --update-checksums there once after the switch.
Teams
A team is a named list of usernames, declared on the registry that uses it and
referenced from that registry's access lists as team:<name>, in the
registry-level access: default and the per-package packages: rules alike:
registries:
corp:
type: upstream
url: https://npm.corp.example.com/
auth:
type: bearer
tokenEnv: CORP_NPM_TOKEN
teams:
platform: [alice, bob]
frontend: [carol, dave]
access: team:platform
packages:
'@corp/ui':
access: [team:platform, team:frontend]
Teams are registry-scoped: a registry can only reference teams it declares itself, never another registry's. To share one roster between registries, use a YAML anchor:
registries:
private:
type: hosted
teams: &corpTeams
platform: [alice, bob]
access: team:platform
corp:
type: upstream
url: https://npm.corp.example.com/
teams: *corpTeams
access: team:platform
A team's members are plain usernames. A team cannot contain another team. An
entry with a : in it is a config error.
A team is only ever reached through the team: prefix: a bare platform in an
access list names the user platform, even when a team of that name
exists. Keeping the two apart means someone who can register a username can
never inherit a team's grants by choosing its name.
Managing teams through the API
By default the teams: map is the roster. npm's team endpoints serve it
read-only and refuse every change. Set teamsManagedBy: api on a hosted
registry to let the auth.admins edit its roster with pnpm team:
auth:
admins: [alice]
registries:
private:
type: hosted
teamsManagedBy: api
teams:
platform: [alice]
packages:
'@corp/*':
access: [team:platform]
- pnpr stores the roster in the hosted store, so every replica serves the same one. A replica rereads it at most 10 seconds after another replica changes it.
- If pnpr cannot read a stored roster, that registry's
team:grants admit nobody until a read succeeds. Other access rules are not affected. - The
teams:map is used only until the first change is stored. After that, editingteams:has no effect. - A team that a
packages:rule names cannot be destroyed. - Only npm hosted registries accept
teamsManagedBy. Non-npm registries (such as Cargo and PyPI) rejectteamsManagedBy: apibecause team management is specific to the npm org/team API.
See Team endpoints.
Changing rules through the API
Set rulesManagedBy: api on a hosted registry to let the
auth.admins change its rules without a config deploy:
registries:
private:
type: hosted
rulesManagedBy: api
packages:
'@corp/*':
access: $authenticated
- An admin can replace the registry-level
access:and theaccess,publish, andunpublishlists of every key thepackages:map declares. The keys themselves stay in the config, because they decide which registry serves a name. - A change applies at once on the replica that took it. pnpr stores changes in the hosted store, so other replicas apply them within 10 seconds.
- A shared cache in front of pnpr, such as a CDN, can keep serving a package
that a change makes private. A packument stays until its
max-age, thepackumentTtl, runs out. A tarball carries nomax-age, so it stays for as long as the cache keeps it. - Resetting the changes brings back the config's rules.
- If pnpr cannot read the stored changes, the registry admits nobody until a read succeeds.
- Only hosted registries accept
rulesManagedBy. npm access grantandnpm access revokeedit the lists of a package thepackages:map declares by name.
See Admin endpoints and Team package access.
oci
Size limits for the container image surface, and the Bearer challenge it offers:
oci:
bearerAuth: false
maxBlobBytes: 10737418240 # 10 GiB
maxManifestBytes: 4194304 # 4 MiB
The values shown are the defaults. Both limits must be greater than zero. See
Container images for bearerAuth.
cors
The exact browser origins allowed to call pnpr across origins. Cross-origin access stays off when the block is absent or its allowlist is empty. See Browsing and discovery.
cors:
allowedOrigins:
- https://registry-ui.example.com
trustedProxies
A list of reverse proxy IP addresses or CIDR blocks trusted to forward client IP addresses in request forwarding headers.
When configured, pnpr evaluates the trusted forwarding header only if the direct socket
peer matches one of the trusted entries. In multi-hop proxy chains, hops are traversed
from right to left, stopping at the first untrusted IP (the real client).
The resolved client IP is recorded on the pnpr::access log span and enforced for
CIDR-restricted bearer tokens. When omitted or empty, forwarding headers are ignored
and the socket peer IP is used directly.
Supports exact IPs, CIDR notation (IPv4 and IPv6), and the "loopback" keyword.
trustedProxyHeader
The specific request header to inspect when behind a trusted proxy:
x-forwarded-for(default): standard comma-separated hops.forwarded: RFC 7239for=...parameters.x-real-ip: single client IP header.
To prevent client spoofing, pnpr only inspects the configured header and does not fall back to other headers.
trustedProxies:
- loopback
- 10.0.0.0/8
- 192.168.1.100
trustedProxyHeader: x-forwarded-for
requestTimeout
The maximum duration that pnpr allows for handling an incoming HTTP request and constructing the response before cancelling it and returning 504 Gateway Timeout.
Defaults to 60s. Set to 0 to disable timeouts.
This timeout bounds request processing, authentication, and response generation. Once response headers are sent and streaming of the response body begins (such as for package tarballs or artifact blobs), active body transfers proceed without being interrupted by the timeout.
Can be specified as an integer number of seconds (e.g. 30) or with a duration suffix (e.g. 30s, 1m).
requestTimeout: 60s
packumentTtl
How long cached upstream packuments remain fresh before pnpr revalidates them against the upstream registry.
Defaults to 5m. Can be overridden per-process with the --packument-ttl CLI flag.
Can be specified as an integer number of seconds (e.g. 10) or with a duration suffix (e.g. 10s, 1m).
packumentTtl: 30s
Security limits and protections
pnpr includes built-in protections against denial-of-service and brute-force attacks:
- Pre-buffer authorization: Package publish and modification endpoints (
PUT /{pkg},PUT /@{scope}/{pkg},PUT /{pkg}/-rev/{rev}) check caller authentication and write permissions before reading or buffering request payloads (up to 100 MiB). Unauthorized or unauthenticated requests are rejected immediately with401 Unauthorizedor403 Forbiddenwithout consuming server memory or network bandwidth. - Login rate limiting: Login attempts (
PUT /-/user/org.couchdb.user:...) are rate-limited to mitigate password brute-force and CPU exhaustion from bcrypt password verification. Failed login attempts are throttled by client IP and by username with a429 Too Many Requestsresponse and aRetry-Afterheader. - Concurrency limits: Intensive operations are governed by concurrency semaphores to protect against resource exhaustion (limiting concurrent password verifications and concurrent large payload buffering).
- Upstream request coalescing (singleflight): Concurrent requests for the same uncached upstream packument are coalesced so multiple concurrent requests result in only a single upstream fetch.
ui
Whether and from where pnpr serves its web UI. See Web UI.
ui:
enabled: true
auth
Without auth.htpasswd.file and without a backend,
accounts live in memory and are lost on restart, and pnpr add-user refuses
to run. Tokens live in memory too unless auth.tokens.file is set. The bundled config stores users in an htpasswd
file and tokens in a local SQLite database:
auth:
htpasswd:
file: ./htpasswd
# Self-registration is disabled when omitted or set to -1.
# Set a non-negative cap to allow new users.
maxUsers: -1
tokens:
# Optional. Defaults to a tokens.db sibling of the htpasswd file.
file: ./tokens.db
Omitted maxUsers means registration disabled, not unlimited registration.
Existing users can still log in. When auth.tokens.file is omitted and
auth.htpasswd.file is set, the token database defaults to tokens.db next to
the htpasswd file.
To share auth state across several stateless pnpr replicas, move users and tokens into a shared SQL database. See Auth backends.
auth.admins lists the usernames that may administer the registry. An admin
may:
- create and remove accounts, change passwords, and revoke tokens through the admin endpoints, even while self-registration is disabled;
- edit the teams of every registry with
teamsManagedBy: api; - change the rules of every registry with
rulesManagedBy: api.
Nobody can self-register an admin's username, a username an
OIDC binding maps to, or a username a
SCIM client provisioned. Create the first admin account with
pnpr add-user.
auth:
admins: [alice]
An auth.oidc list configures OpenID Connect browser sign-in and keyless CI
publishing alongside the password backend. See
OpenID Connect.
auth.scim lets an identity provider deprovision accounts. See
SCIM deprovisioning.
secret and secrets
# Master secret:
secret: ${PNPR_SECRET}
# Or independently configured subkeys:
#secrets:
# cache: ${PNPR_CACHE_SECRET}
# oci: ${PNPR_OCI_SECRET}
pnpr uses secret keys to isolate private cache namespaces and sign bearer tokens:
cache: private cache namespaces: private resolution-cache entries and private per-registry cache directories are keyed with it, so on-disk paths reveal neither registry names nor credentials.oci: bearer token signing key for the OCI registry surface.
When secret: is configured, the cache key defaults to the root secret
(preserving existing on-disk cache directories across upgrades), while the OCI
bearer token signing key derives a domain-separated subkey from secret: via
SHA-256 (pnpr OCI token signing v1).
To rotate the OCI bearer token signing key without invalidating the resolution
and upstream caches (or vice versa), configure independent secrets via
secrets.cache and secrets.oci. Each explicit secret must be at least 16
bytes.
When neither secret nor secrets is configured, a random 32-byte secret is
generated per process. Private cache entries and issued OCI bearer tokens only
survive for that process's lifetime. Set an explicit secret in multi-replica
or production deployments.
Registry, resolver, artifact, and pipeline surfaces
pnpr exposes four independently deployable HTTP surfaces:
- The registry surface: package and tarball reads, publish,
unpublish, dist-tags, and search, on each ecosystem's base and on every
/~<name>/endpoint under it, for every ecosystem the config declares. It has no config toggle: it is served exactly when at least one registry is declared underregistries:. The CLI flag--disable-registryturns it off for one process. - The resolver surface: the pnpr install-accelerator routes
(
GET /-/pnpr,POST /-/pnpr/v0/resolve, andPOST /-/pnpr/v0/verify-lockfile). It is enabled by default; the CLI flag--disable-resolveroverrides the setting:
resolver:
enabled: true
- The shared-artifact surface: the three organization-scoped endpoints of
the shared side-effects cache proof of
concept. It is off by default and is a top-level peer of
resolver, so an I/O-bound artifact tier can scale independently of a compute-bound resolver:
artifacts:
enabled: true
orgs:
acme:
access: [ci-builder, alice, bob]
publish: ci-builder
The CLI flag --disable-artifacts overrides this setting. With artifacts off,
its routes are not mounted and the GET /-/pnpr handshake advertises an empty
artifact-version list. The handshake itself is served when any surface is
enabled, so an artifact-only tier is discoverable.
orgs declares the organizations this server keeps build output for, and who
may read and who may publish what each one owns. An organization owns three
things:
- its signed artifacts: shared side effects
and
pnpm pipelinetask results, - its Cargo compilation cache,
- its pipeline run records.
access and publish list pnpr usernames, or $authenticated for every
signed-in account. Teams are declared per registry, so team: entries are
refused here. Publishing also requires access. An empty list denies
everyone. An organization nobody may read is invisible: requests for it answer
as if it did not exist. An undeclared organization is unavailable.
orgs is read even when enabled is false, for a server that only stores
pipeline runs.
quota bounds what the artifact store keeps, in GiB: ownerGiB for one
owner (an organization, or a cache name), defaulting to 1, and totalGiB for
all of them together, defaulting to 10. Both must be positive, and ownerGiB
cannot exceed totalGiB. A pnpm pipeline task result can hold
release binaries, so a server that stores them usually needs more:
artifacts:
quota:
ownerGiB: 50
totalGiB: 200
- The pipeline run surface: the endpoints that store and serve
pnpm pipelinerun records, a peer of the artifact store. It is off by default:
pipeline:
enabled: true
Who may read and submit runs is decided by artifacts.orgs.
Something must be served: a config with no registries (or the registry surface disabled by flag), the resolver disabled, artifacts disabled, and the pipeline surface disabled is a startup error.
The health endpoint (/-/ping) and the account endpoints (login, whoami, and
token management) are always served, whichever surfaces are enabled, so
pnpm login works even against a resolver-only or artifact-only server. See
HTTP endpoints.
routes
Resolver route classification (see
Install acceleration) treats the
official npm registry and npm.jsr.io as public out of the box. If clients resolve against
other registries that serve anonymously-readable content, declare them as
public routes so their resolutions can be cached and shared across all callers:
routes:
public:
- registry: https://registry.mirror.example.com/
- registry: https://npm.corp.example.com/
package: '@oss/*'
Each rule may name a registry origin, a package glob, or both. Rules fail
closed on a typo: a present-but-unparsable registry URL or package glob
drops the whole rule (with a warning) rather than widening it.
The resolver refuses to connect to loopback, private, and link-local
addresses, other than the --public-url host. To resolve through an upstream
on an internal network, list its addresses in allowedPrivateNetworks, as IPs
or CIDR blocks:
routes:
allowedPrivateNetworks:
- 10.0.0.0/8
osv
Local OSV checks can hide or reject known vulnerable npm versions without live OSV API calls:
osv:
enabled: true
path: ./osv/npm/all.zip
path may point to an OSV npm database zip or an extracted JSON directory. When
omitted, pnpr looks for <cache>/osv/npm/all.zip. The same feature can be
enabled from the CLI with --osv and --osv-db.
log
log:
type: stdout
format: pretty # or `json`
level: error # trace, debug, http, info, warn, or error
format defaults to pretty and level to info. Only type: stdout is
implemented. Any other value logs a warning, and pnpr still writes to stdout.
RUST_LOG always overrides the configured level. See the
CLI reference.
Access logs and request IDs
Every request emits an access log record under the tracing target pnpr::access at info level:
request_id: incomingx-request-idheader (if valid printable ASCII, <=128 characters) or a generated RFC 4122 UUID v4. Also echoed in the responsex-request-idheader.method: HTTP method.uri: request path and query string.client_ip: client IP resolved according totrustedProxiesandtrustedProxyHeader.user: authenticated username, or"-"when unauthenticated.user_agent:User-Agentheader value, or"-"when omitted.status: HTTP response status code.latency_ms: request round-trip processing duration in milliseconds.bytes: response body size in bytes, derived fromContent-Length(0 for chunked or streamed responses without length headers).
Admin audit logs
Administrative and destructive operations emit structured audit records under the tracing target pnpr::audit at info level. When structured logging is enabled (format: json), these events output as JSON objects that inherit the active request span context, including request_id, client_ip, method, and uri. The pnpr::audit=info directive is included across all configured log levels so audit events are retained by default. Operators can filter or silence them via RUST_LOG (for example, RUST_LOG=pnpr::audit=off).
Audit events record the action, the actor (the authenticated username, "scim" for SCIM actions, or "-" when unauthenticated), and operation-specific attributes:
- User management:
user.create: creating an account (actor,user).user.password_update: changing an account password (actor,user).user.token_revoke: revoking user tokens (actor,user,token_key).user.delete: deleting an account (actor,user,tokens_revoked).user.register: self-registering a new account (actor,user).
- Rule overrides:
rules.replace: replacing dynamic package access rules (actor,registry,ecosystem,version).rules.delete: removing dynamic package access rules (actor,registry).
- Team administration and package access:
team.create: creating a team (actor,scope,team).team.destroy: deleting a team (actor,scope,team).team.member_add: adding a user to a team (actor,scope,team,user).team.member_remove: removing a user from a team (actor,scope,team,user).team.access_grant: granting package permissions to a team (actor,scope,team,package,permissions).team.access_revoke: revoking package permissions from a team (actor,scope,team,package).
- SCIM provisioning:
scim.user_create: provisioning a SCIM user (actor,user,active).scim.user_replace: replacing SCIM user attributes (actor,user,active).scim.user_patch: updating SCIM user status (actor,user,active).scim.user_delete: deprovisioning a SCIM user (actor,user).
- Package unpublishing and mutations:
package.unpublish: unpublishing an entire package (actor,package,registry).package.unpublish_tarball: unpublishing a specific package version tarball (actor,package,tarball,version,registry).package.update_packument: modifying packument metadata directly (actor,package,registry).package.update_maintainers: changing a package's maintainers (actor,package,registry).package.dist_tag_set: assigning a dist-tag (actor,package,tag,version,registry).package.dist_tag_remove: deleting a dist-tag (actor,package,tag,registry).oci.delete_blob: deleting an OCI blob (actor,repository,digest).oci.delete_manifest: deleting an OCI manifest (actor,repository,reference).
Prometheus metrics
pnpr serves Prometheus metrics in the standard text format (text/plain; version=0.0.4) only when the config has a metrics block. The block needs a token, a listen address, or both:
metrics:
token: ${PNPR_METRICS_TOKEN}
listen: 127.0.0.1:9464
token: the bearer token a scraper sends, at least 32 characters. Withoutlisten, pnpr serves metrics atGET /-/metricsandGET /metricson its main address, and refuses a request without the token with401.listen: an address for a second listener that serves onlyGET /metrics. The main address then serves no metrics. Expose the main address and keep this one on a private network. Withtokenset, the second listener requires it too.
A Prometheus scrape job sends the token with authorization:
scrape_configs:
- job_name: pnpr
authorization:
credentials_file: /etc/prometheus/pnpr-metrics-token
static_configs:
- targets: ["pnpr.internal:9464"]
The metrics are:
pnpr_http_requests_in_flight(gauge): number of requests currently being handled.pnpr_http_requests_total(counter): requests served, labelled bymethod(normalized to standard HTTP methods orOTHER) andstatus.pnpr_http_request_duration_seconds(histogram): request duration distribution across buckets from 0.005s to 10.0s.pnpr_http_response_bytes_total(counter): total response payload bytes sent, derived fromContent-Length.pnpr_cache_hits_total(counter): packument cache hits on upstream proxies.pnpr_cache_misses_total(counter): packument cache misses requiring upstream fetches.pnpr_login_rate_limits_total(counter): login attempts throttled by rate limiting.pnpr_build_info(gauge): server build version info.
Environment variable substitution
Any ${ENV_VAR} in the config is substituted from the environment before
parsing, so secrets can be kept out of the file:
s3:
bucket: my-pnpr-packages
accessKeyId: ${PNPR_S3_ACCESS_KEY_ID}
secretAccessKey: ${PNPR_S3_SECRET_ACCESS_KEY}
${ENV_VAR-default} supplies default when ENV_VAR is unset, and
${ENV_VAR:-default} also when it is empty. An unset or empty variable without
a default becomes an empty string, and pnpr logs a warning.
Unresolved ${ENV_VAR} references in secret-bearing fields (secret,
secrets.cache, secrets.oci, s3.secretAccessKey, auth.scim.token,
OIDC clientSecret, SQL/libsql authToken, upstream token and
bearerToken, and upstream headers values) fail fast at startup with a fatal configuration error. This prevents accidental fallback to
empty credentials or per-process secrets when an environment variable is unset.