feat(registry): persist pnpr users and tokens to disk#11977
Conversation
Backs UserStore with a verdaccio-shaped htpasswd file (bcrypt $2y$
hashes, atomically rewritten on every adduser) and TokenStore with a
SQLite database that stores SHA-256 token hashes plus the per-record
fields the upcoming /-/npm/v1/tokens surface will need (created_at,
last_used_at, readonly, cidr_whitelist).
Configuration mirrors verdaccio's auth.htpasswd.{file,max_users}
under the existing YAML schema; tokens default to a tokens.db
sibling of htpasswd, overridable via auth.tokens.file. max_users=-1
disables registration end-to-end. Both files are written via
tmp+rename and loaded eagerly on startup so a malformed htpasswd
fails fast rather than booting with a silent empty user list.
Closes #11974.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (5)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (2)
📜 Recent review details⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
🧰 Additional context used📓 Path-based instructions (1)registry/crates/**/*.rs📄 CodeRabbit inference engine (registry/AGENTS.md)
Files:
🔇 Additional comments (2)
📝 WalkthroughWalkthroughAdds persistent auth to pnpm-registry: bcrypt-hashed htpasswd user storage and SQLite-backed SHA-256 token hashes, new auth config parsing, server wiring to load/persist auth state, expanded errors/dependencies, and acceptance tests for persistence, corruption diagnostics, compatibility, and registration policy. ChangesPersistent Authentication for pnpm-registry
Sequence DiagramsequenceDiagram
participant Client
participant Serve as serve(config)
participant AuthState as AuthState::load
participant UserStore as UserStore::open
participant TokenStore as TokenStore::open
participant Htpasswd as Htpasswd File
participant SQLite as SQLite DB
participant Handler as add_or_login Handler
Note over Serve,AuthState: Startup with persisted auth
Serve->>AuthState: load(config.auth)
AuthState->>UserStore: open(htpasswd_path)
UserStore->>Htpasswd: read & parse htpasswd (bcrypt)
Htpasswd-->>UserStore: bcrypt hashes loaded
AuthState->>TokenStore: open(tokens_db_path)
TokenStore->>SQLite: init schema + load all token records
SQLite-->>TokenStore: token hashes + metadata
TokenStore-->>AuthState: in-memory cache ready
AuthState-->>Serve: AuthState ready
Note over Client,Handler: New user registration flow
Client->>Handler: PUT /-/user/org.couchdb.user:newuser
Handler->>UserStore: add_or_login (async)
UserStore->>UserStore: spawn_blocking bcrypt hash
UserStore->>UserStore: enforce MaxUsers under mutex
UserStore->>Htpasswd: persist htpasswd atomically
UserStore-->>Handler: Created
Handler->>TokenStore: issue token (async)
TokenStore->>TokenStore: mint raw token, hash SHA-256
TokenStore->>SQLite: upsert hash -> TokenRecord
SQLite-->>TokenStore: persisted
TokenStore-->>Handler: raw token returned
Handler-->>Client: 201 + token
Note over Client,Handler: After restart, token lookup
Client->>Serve: Request with Authorization: Bearer <token>
Handler->>TokenStore: lookup(raw_token)
TokenStore->>TokenStore: hash raw token, consult in-memory map
TokenStore-->>Handler: username
Handler-->>Client: 200 + data
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Review Summary by QodoPersist pnpr users and tokens to disk for hosted registry durability
WalkthroughsDescription• Persist user accounts to htpasswd file with bcrypt hashing • Store bearer tokens in SQLite database with SHA-256 hashing • Add YAML configuration for auth file paths and user registration caps • Implement crash-safe atomic writes and eager startup validation • Support Apache htpasswd tool compatibility for cross-tool verification Diagramflowchart LR
Config["Config with auth paths"]
AuthState["AuthState::load"]
UserStore["UserStore<br/>htpasswd file"]
TokenStore["TokenStore<br/>SQLite DB"]
AddUser["adduser endpoint"]
Verify["Bearer/Basic auth"]
Config -->|"parse auth block"| AuthState
AuthState -->|"open file"| UserStore
AuthState -->|"open DB"| TokenStore
AddUser -->|"bcrypt hash"| UserStore
AddUser -->|"SHA-256 hash"| TokenStore
Verify -->|"lookup token"| TokenStore
Verify -->|"verify password"| UserStore
File Changes1. registry/crates/pnpm-registry/src/auth.rs
|
Micro-Benchmark ResultsLinux |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
registry/crates/pnpm-registry/tests/auth_persistence.rs (1)
138-142: 💤 Low valueTempDir dropped immediately — inconsistent with other tests.
The inline
TempDir::new().unwrap()is dropped at the end of this expression, deleting the directory before the test proceeds. The test still passes becauseAuthState::loadonly reads auth paths (htpasswd/tokens), not storage. For consistency with other tests and to avoid mysterious failures if test logic evolves:♻️ Bind TempDir to preserve lifetime
+ let storage = TempDir::new().unwrap(); let config = persistent_config( - TempDir::new().unwrap().path().to_path_buf(), + storage.path().to_path_buf(), htpasswd.clone(), auth_dir.path().join("tokens.db"), );🤖 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 `@registry/crates/pnpm-registry/tests/auth_persistence.rs` around lines 138 - 142, The TempDir created inline is dropped immediately; bind it to a local variable to preserve the directory for the duration of the test. Replace the inline TempDir::new().unwrap() used in persistent_config with a stored value (e.g., let temp_dir = TempDir::new().unwrap();) and pass temp_dir.path().to_path_buf() into persistent_config so the temporary directory remains alive while the test (and functions like AuthState::load) run.
🤖 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 `@registry/crates/pnpm-registry/src/auth.rs`:
- Around line 543-560: The fresh_secret function currently derives a secret from
time/pid/stack addresses; replace its implementation to use an OS-backed CSPRNG
(e.g., add getrandom or rand with rand::rngs::OsRng to Cargo.toml) and fill the
32-byte array from the OS RNG instead of hashing deterministic inputs; update
fresh_secret to call getrandom (or OsRng) to populate the [u8;32] and
handle/report any error (propagate or unwrap with clear message) so
TokenStore::issue receives a cryptographically strong secret.
In `@registry/crates/pnpm-registry/src/config.rs`:
- Around line 93-96: The docs claim that auth.htpasswd.max_users accepts “+inf”
but HtpasswdFile.max_users is deserialized as Option<i64> and then converted
with MaxUsers::from_yaml(i64), so YAML infinity tokens will fail; either remove
“+inf” from the docs or implement custom deserialization to treat YAML
+inf/+.inf as MaxUsers::Unlimited. To fix in code, add a custom Deserialize impl
or a serde with deserialize_with for HtpasswdFile.max_users that accepts integer
values into Option<i64> and maps floating-point infinity tokens (positive
infinity) to Some(MaxUsers::Unlimited) via MaxUsers::from_yaml (or adjust
MaxUsers to accept a sentinel), update MaxUsers::from_yaml to handle the
sentinel if needed, and add a unit test that deserializes
auth.htpasswd.max_users: +inf to assert it becomes Unlimited; alternatively
update the documentation string to drop “+inf” if you don’t implement the
deserializer.
---
Nitpick comments:
In `@registry/crates/pnpm-registry/tests/auth_persistence.rs`:
- Around line 138-142: The TempDir created inline is dropped immediately; bind
it to a local variable to preserve the directory for the duration of the test.
Replace the inline TempDir::new().unwrap() used in persistent_config with a
stored value (e.g., let temp_dir = TempDir::new().unwrap();) and pass
temp_dir.path().to_path_buf() into persistent_config so the temporary directory
remains alive while the test (and functions like AuthState::load) run.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 97d3bb51-c3c6-47f8-8dfe-9c1ab8e6d50c
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
Cargo.tomlregistry/crates/pnpm-registry/Cargo.tomlregistry/crates/pnpm-registry/src/auth.rsregistry/crates/pnpm-registry/src/config.rsregistry/crates/pnpm-registry/src/error.rsregistry/crates/pnpm-registry/src/lib.rsregistry/crates/pnpm-registry/src/server.rsregistry/crates/pnpm-registry/tests/auth_persistence.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
- GitHub Check: Lint and Test (macos-latest)
- GitHub Check: Lint and Test (windows-latest)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Code Coverage
- GitHub Check: Analyze (javascript)
- GitHub Check: Compile & Lint
🧰 Additional context used
📓 Path-based instructions (3)
registry/crates/**/Cargo.toml
📄 CodeRabbit inference engine (registry/AGENTS.md)
registry/crates/**/Cargo.toml: When using existing pacquet-* crates, declare them in the root [workspace.dependencies] and use { workspace = true } in the crate's Cargo.toml
New registry-only crates must be placed under registry/crates// and named pnpm-registry- in Cargo.toml, with the package name using the pnpm-registry- prefix exclusively
Do not reach for the pacquet- prefix to name new registry-side crates; use pnpm-registry- prefix exclusively for registry-only crates
Files:
registry/crates/pnpm-registry/Cargo.toml
registry/**/Cargo.toml
📄 CodeRabbit inference engine (registry/AGENTS.md)
Add new registry-only crates to [workspace.dependencies] at the root with the pnpm-registry- prefix so other crates can use { workspace = true }
Files:
registry/crates/pnpm-registry/Cargo.toml
registry/crates/**/*.rs
📄 CodeRabbit inference engine (registry/AGENTS.md)
Follow the pacquet code-style guide (../pacquet/CODE_STYLE_GUIDE.md) for Rust-level conventions including imports, naming, ownership, error handling, and test layout
Files:
registry/crates/pnpm-registry/src/lib.rsregistry/crates/pnpm-registry/src/error.rsregistry/crates/pnpm-registry/src/server.rsregistry/crates/pnpm-registry/tests/auth_persistence.rsregistry/crates/pnpm-registry/src/config.rsregistry/crates/pnpm-registry/src/auth.rs
🔇 Additional comments (21)
Cargo.toml (1)
85-85: LGTM!registry/crates/pnpm-registry/Cargo.toml (1)
25-25: LGTM!Also applies to: 32-32
registry/crates/pnpm-registry/src/error.rs (1)
90-125: LGTM!Also applies to: 169-175
registry/crates/pnpm-registry/src/lib.rs (1)
22-26: LGTM!Also applies to: 29-29
registry/crates/pnpm-registry/src/config.rs (1)
58-92: LGTM!Also applies to: 97-162, 168-337, 591-663
registry/crates/pnpm-registry/src/auth.rs (7)
1-66: LGTM!
68-177: LGTM!
179-324: LGTM!
356-441: LGTM!
443-466: LGTM!
468-537: LGTM!
591-814: LGTM!registry/crates/pnpm-registry/src/server.rs (5)
13-13: LGTM!Also applies to: 40-53
55-99: LGTM!
101-113: LGTM!
485-527: LGTM!
1035-1060: LGTM!registry/crates/pnpm-registry/tests/auth_persistence.rs (4)
1-58: LGTM!
60-126: LGTM!
151-204: LGTM!
206-230: LGTM!
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #11977 +/- ##
==========================================
+ Coverage 88.03% 88.14% +0.10%
==========================================
Files 228 228
Lines 28068 28480 +412
==========================================
+ Hits 24711 25103 +392
- Misses 3357 3377 +20 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Integrated-Benchmark Report (Linux)Scenario: Isolated linker: fresh restore, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 1.96777778192,
"stddev": 0.09342484875771102,
"median": 1.9580025243199999,
"user": 2.80142354,
"system": 3.286039,
"min": 1.8435277543200002,
"max": 2.14547043032,
"times": [
1.9115061003200002,
1.9048591703200002,
1.8435277543200002,
2.0276237623199997,
1.9134456783200002,
2.0440368043199997,
1.87321355332,
2.14547043032,
2.01153519532,
2.0025593703199998
]
},
{
"command": "pacquet@main",
"mean": 1.9742864198199999,
"stddev": 0.06746030933602035,
"median": 1.96912252582,
"user": 2.7897805399999998,
"system": 3.3093415999999998,
"min": 1.8675589253200002,
"max": 2.11317951232,
"times": [
1.8675589253200002,
1.9336737543200002,
2.01017555032,
1.94166034432,
1.9831852413200002,
1.92267856732,
2.02916954132,
1.95505981032,
1.9865229513200002,
2.11317951232
]
}
]
}Scenario: Isolated linker: fresh restore, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.6500932656600001,
"stddev": 0.02241370768005456,
"median": 0.6424916378600001,
"user": 0.36488186,
"system": 1.3515652399999998,
"min": 0.6330469173600001,
"max": 0.7095458213600001,
"times": [
0.7095458213600001,
0.6609341913600001,
0.6330469173600001,
0.6373782973600001,
0.6407561553600001,
0.6425668673600001,
0.6441161353600001,
0.63707491236,
0.65309695036,
0.64241640836
]
},
{
"command": "pacquet@main",
"mean": 0.6384122295600001,
"stddev": 0.011830419637898609,
"median": 0.63889951686,
"user": 0.37200346000000006,
"system": 1.3353949399999998,
"min": 0.6235060463600001,
"max": 0.6676556623600001,
"times": [
0.6676556623600001,
0.6235060463600001,
0.6404469593600001,
0.6386655773600001,
0.6391334563600001,
0.6311087843600001,
0.63099336636,
0.6394464403600001,
0.64212334136,
0.6310426613600001
]
}
]
}Scenario: Isolated linker: fresh install, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.2683645768,
"stddev": 0.02266870499011813,
"median": 2.2603091037,
"user": 3.90191412,
"system": 3.03355992,
"min": 2.2430828562,
"max": 2.3093345022,
"times": [
2.3059813732,
2.2526138132,
2.2543389021999998,
2.2602373451999997,
2.2603808622,
2.2616545562,
2.2430828562,
2.3093345022,
2.2563438232,
2.2796777342
]
},
{
"command": "pacquet@main",
"mean": 2.2766221254000003,
"stddev": 0.03947655940368133,
"median": 2.2778195001999997,
"user": 3.88905902,
"system": 3.0286793199999997,
"min": 2.2010253602,
"max": 2.3213412231999997,
"times": [
2.2593740332,
2.2491162652,
2.2500010491999998,
2.3213412231999997,
2.2962649672,
2.2553670662,
2.3099299152,
2.2010253602,
2.3044177782,
2.3193835962
]
}
]
}Scenario: Isolated linker: fresh install, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 1.4669470359800003,
"stddev": 0.021461817179532622,
"median": 1.4614865913800001,
"user": 1.7804941200000002,
"system": 1.82304666,
"min": 1.4427709823800001,
"max": 1.4975596963800002,
"times": [
1.4913392193800001,
1.4633370783800002,
1.45560113338,
1.44407101238,
1.4596361043800001,
1.4716981243800002,
1.4427709823800001,
1.4966490783800002,
1.44680793038,
1.4975596963800002
]
},
{
"command": "pacquet@main",
"mean": 1.46905950698,
"stddev": 0.02105900000281451,
"median": 1.46420254938,
"user": 1.7622540199999996,
"system": 1.8219830600000002,
"min": 1.4468804183800001,
"max": 1.5149337413800001,
"times": [
1.47856599538,
1.45668227938,
1.4468804183800001,
1.44757486638,
1.5149337413800001,
1.48332507638,
1.47975809838,
1.47172281938,
1.4563313853800002,
1.4548203893800002
]
}
]
} |
|
| Branch | pr/11977 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result milliseconds (ms) (Result Δ%) | Upper Boundary milliseconds (ms) (Limit %) |
|---|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 2,268.36 ms(-9.50%)Baseline: 2,506.59 ms | 3,007.91 ms (75.41%) |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 1,466.95 ms(-11.08%)Baseline: 1,649.72 ms | 1,979.66 ms (74.10%) |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 1,967.78 ms(-3.25%)Baseline: 2,033.93 ms | 2,440.72 ms (80.62%) |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 650.09 ms(-1.44%)Baseline: 659.62 ms | 791.54 ms (82.13%) |
- TokenStore's per-process secret now comes from getrandom (OS-backed CSPRNG) instead of time/pid/stack address. Tokens are derived from this secret + a per-issue nonce, so weak entropy was making mint outputs guessable to an attacker who could bound those inputs. - Reorder derives on AuthConfig / HtpasswdConfig / TokensConfig / MaxUsers to satisfy perfectionist::derive-ordering (prefix-then- alphabetical: Debug, Default first, then the rest). - Re-export auth::identify so the rustdoc link from the now-public UserStore::verify resolves; rustdoc::private-intra-doc-links no longer fails the workspace doc build. - Drop the inaccurate "+inf" mention from MaxUsers' doc — serde-saphyr treats +inf as a float and can't deserialize it into i64, so the only way to get Unlimited is to omit max_users.
Summary
Backs
pnpr's user and token stores with on-disk persistence so an operator can run pnpr as a hosted registry without losing every account on the next container redeploy. Closes #11974.UserStore↔ verdaccio-shaped htpasswd file (bcrypt$2y$10$…, atomically rewritten on every adduser; verifiable by Apache'shtpasswd -v).TokenStore↔ SQLite database storing SHA-256 hashes of bearer tokens (raw tokens never hit disk) plus the per-record fields the upcoming/-/npm/v1/tokenssurface will need (created_at,last_used_at,readonly,cidr_whitelist).auth:block:auth.htpasswd.{file,max_users}andauth.tokens.file(defaults to atokens.dbsibling of the htpasswd file).auth.htpasswd.max_users: -1disables registration end-to-end (returns 403).API changes
pnpm_registry::router(config)stays in-memory (test-only convenience).pnpm_registry::router_with_auth(config, auth)for callers that pre-load anAuthState.serve(config)doesAuthState::load(&config.auth)?before binding the listener.Test plan
cargo test -p pnpm-registry— 70 tests (14 unit + 26 auth_publish + 9 registry_mock + 17 server + 4 new auth_persistence) all pass.cargo clippy -p pnpm-registry --all-targets -- --deny warningsclean.cargo fmt --check -p pnpm-registryclean.auth_persistenceintegration tests cover the three acceptance criteria from the issue: restart-survival, corrupted-file diagnostic, and cross-tool compat with Apachehtpasswd -v -b.Written by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit
New Features
Tests