paquette 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +315 -34
- data/lib/paquette/cache_validation.rb +62 -0
- data/lib/paquette/conditional_get.rb +371 -0
- data/lib/paquette/gem_server/cooldown_repository.rb +186 -0
- data/lib/paquette/gem_server/directory_gem_repository.rb +449 -115
- data/lib/paquette/gem_server/gem_repacker/build_time.rb +40 -0
- data/lib/paquette/gem_server/gem_repacker/rooted_package.rb +65 -0
- data/lib/paquette/gem_server/gem_repacker.rb +166 -139
- data/lib/paquette/gem_server/gem_repository.rb +219 -47
- data/lib/paquette/gem_server/personalizer.rb +279 -43
- data/lib/paquette/gem_server/read_gated_repository.rb +101 -58
- data/lib/paquette/gem_server/readonly_repository.rb +20 -0
- data/lib/paquette/gem_server/spec_validator.rb +203 -0
- data/lib/paquette/gem_server.rb +758 -272
- data/lib/paquette/index_page/masthead.svg +4 -0
- data/lib/paquette/index_page/page.html +133 -0
- data/lib/paquette/index_page.rb +67 -0
- data/lib/paquette/npm_repacker.rb +199 -121
- data/lib/paquette/npm_server/directory_npm_repository.rb +438 -104
- data/lib/paquette/npm_server/npm_repository.rb +251 -38
- data/lib/paquette/npm_server/personalizer.rb +207 -0
- data/lib/paquette/npm_server/read_gated_repository.rb +185 -0
- data/lib/paquette/npm_server.rb +565 -71
- data/lib/paquette/otp_gate.rb +49 -0
- data/lib/paquette/regexp_timeout.rb +86 -0
- data/lib/paquette/routes.rb +216 -51
- data/lib/paquette/safe_url.rb +32 -0
- data/lib/paquette/subdomain_router.rb +50 -38
- data/lib/paquette/tarball.rb +312 -0
- data/lib/paquette/token_authorization.rb +82 -0
- data/lib/paquette/version.rb +2 -1
- data/lib/paquette.rb +47 -10
- data/rbi/paquette.rbi +3773 -0
- data/sig/paquette.rbs +3280 -0
- data/test/conformance/gem_server_conformance_test.rb +152 -0
- data/test/conformance/rspec_exclusions.rb +112 -0
- data/test/docker/Dockerfile +11 -0
- data/test/gem_server/bundler_dependency_resolution_test.rb +129 -0
- data/test/gem_server/bundler_install_test.rb +54 -66
- data/test/gem_server/compact_index_benchmark_test.rb +272 -0
- data/test/gem_server/compact_index_ranges_test.rb +344 -0
- data/test/gem_server/cooldown_repository_test.rb +638 -0
- data/test/gem_server/directory_gem_repository_test.rb +485 -0
- data/test/gem_server/gem_repacker_test.rb +143 -7
- data/test/gem_server/gem_repository_test.rb +192 -0
- data/test/gem_server/hostile_gemspec_urls_test.rb +116 -0
- data/test/gem_server/http_caching_test.rb +683 -0
- data/test/gem_server/namespace_mount_test.rb +203 -0
- data/test/gem_server/otp_dialect_test.rb +37 -0
- data/test/gem_server/personalizer_selective_test.rb +124 -0
- data/test/gem_server/personalizer_test.rb +193 -0
- data/test/gem_server/prerelease_specs_test.rb +208 -0
- data/test/gem_server/read_gated_repository_test.rb +3 -3
- data/test/gem_server/readonly_repository_test.rb +108 -0
- data/test/gem_server/spec_validator_test.rb +0 -0
- data/test/gem_server/version_shapes_test.rb +178 -0
- data/test/gem_server_test.rb +212 -11
- data/test/index_page_test.rb +48 -0
- data/test/instrumentation_test.rb +186 -0
- data/test/npm_repacker_test.rb +184 -70
- data/test/npm_server/directory_npm_repository_test.rb +271 -0
- data/test/npm_server/docker_client_test.rb +312 -0
- data/test/npm_server/http_caching_test.rb +526 -0
- data/test/npm_server/namespace_mount_test.rb +86 -0
- data/test/npm_server/npm_install_test.rb +144 -0
- data/test/npm_server/npm_server_test.rb +339 -0
- data/test/npm_server/otp_dialect_test.rb +54 -0
- data/test/npm_server/personalizer_selective_test.rb +87 -0
- data/test/npm_server/personalizer_test.rb +184 -0
- data/test/npm_server/read_gated_repository_test.rb +107 -0
- data/test/npm_server/regression_test.rb +224 -0
- data/test/otp_gate_test.rb +68 -0
- data/test/platform_gems_test.rb +763 -0
- data/test/rack_cache_test.rb +176 -0
- data/test/regexp_fuzz_test.rb +216 -0
- data/test/regexp_linearity_test.rb +418 -0
- data/test/regexp_timeout_test.rb +168 -0
- data/test/repository_fingerprint_test.rb +52 -0
- data/test/route_match_budget_test.rb +113 -0
- data/test/route_recognition_test.rb +78 -0
- data/test/routes_test.rb +44 -0
- data/test/safe_url_test.rb +77 -0
- data/test/subdomain_router_integration_test.rb +4 -4
- data/test/subdomain_router_test.rb +14 -4
- data/test/tarball_test.rb +134 -0
- data/test/test_helper.rb +301 -0
- data/test/token_authorization_test.rb +88 -0
- metadata +82 -8
- data/lib/paquette/npm_server/gated_npm_repository.rb +0 -66
- data/test/npm_repacker_sourcemap_test.rb +0 -108
- data/test/npm_server_test.rb +0 -140
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2e249bb3b01d7ddc7e3b78968579b004ea9ff8862895f51cdd40f814e5ab80f
|
|
4
|
+
data.tar.gz: f7c8e608ad032792fdecfaf2b66e5f0ac975932ce028b3cfdf5066c758401799
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de59c104c34db56212e2c01b0f6feaab8b96f1ec8040c252784ffd4efd47c1bdba89307634617bd574a2ad4cb0558cf36ba8d54f12e75acc559e6b06ae137a07
|
|
7
|
+
data.tar.gz: ddd5a0a2353161eb730ca6ca99f91eac33e1327fbe200e14965ec0d46768845a12305442634b5b6eddd926985ab5f29d119fa1b54b094ab2afc61a07f58d7d23
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Ship signature files that name every external type. A type sord could not resolve — `Rack::Request`, `Gem::Specification`, `File::Stat` and the like — used to land in `rbi/paquette.rbi` and `sig/paquette.rbs` as a `SORD_ERROR_` constant, which no type checker can read; those types are now root-anchored in the YARD tags and come through by name, and every constant carries a type of its own
|
|
6
|
+
- Document every method and attribute with YARD type tags, and cut the code comments down to what a thing does and why the non-obvious parts are the way they are. `rake yard` builds the docs; `rake rbi` and `rake rbs` generate `rbi/paquette.rbi` and `sig/paquette.rbs` from the tags via sord, and the signature files ship with the gem
|
|
7
|
+
- Store, index, download and yank a platform build as its own artifact. `DirectoryGemRepository` keyed a gem by name and version alone, so a push of `nokogiri-1.16.0-java` wanted the plain build's path and was refused 409 "already exists"; a gem is now filed under the filename `Gem::Specification#file_name` gives it, which leaves a plain-ruby gem's path exactly where it was
|
|
8
|
+
- Make `GemAlreadyExists` and `GemYanked` mean "this name, version *and* platform", so a yanked java build neither hides its ruby sibling nor blocks it from being pushed
|
|
9
|
+
- Take `platform` on `DELETE /api/v1/gems/yank`, the param `gem yank --platform` sends. An absent or blank one means `ruby` and yanks only the plain build; a version that already carries the platform (`1.16.0-java`) is accepted too
|
|
10
|
+
- Validate an uploaded gemspec's `platform` before it becomes a filename — a bounded charset that admits the dashes a real platform needs and refuses path separators, a leading dot, `..`, NUL, newlines and invalid UTF-8 (security)
|
|
11
|
+
- Validate the `gem_name`, `version` and `platform` params of a yank, which reached `File.join` unexamined (security)
|
|
12
|
+
- Drop the `/third push/` exclusion from the conformance suite: it runs 550 examples now, up from 104
|
|
13
|
+
- Report a platform gem's real platform, with a bare version beside it, in `/specs.4.8`, `/latest_specs.4.8`, the dependency API, `/api/v1/versions` and search — all five used to advertise `nokogiri-1.16.0-java` as version `"1.16.0-java"` on platform `"ruby"`, which is a version no client can resolve on a platform that is not the gem's
|
|
14
|
+
- Carry a `Gem::Version` rather than a String in the legacy Marshal indexes, as rubygems.org does — `Gem::SpecFetcher` sorts what it unmarshals, and Strings sort `1.10.0` below `1.9.0`
|
|
15
|
+
- Name the latest version of each gem per *platform* in `/latest_specs.4.8`, so a java or arm64-darwin build is not hidden behind the plain-ruby one
|
|
16
|
+
- Serve `/api/v1/dependencies` and `/api/v1/dependencies.json`, which were documented and implemented but had no route; the unsuffixed one answers Marshal, the way Bundler's legacy fallback reads it
|
|
17
|
+
- Report the gem's own authors, summary and platform from `/api/v1/search.json` instead of the placeholders it used to send for every gem
|
|
18
|
+
- Serve `/prerelease_specs.4.8` and `/prerelease_specs.4.8.gz`, which `gem install --pre` and mirroring tools fetch and which used to 404
|
|
19
|
+
- Keep prereleases out of `/specs.4.8` and `/latest_specs.4.8`, which used to list them alongside releases, so a gem with only prereleases no longer has a "latest" release
|
|
20
|
+
- Run RubyGems' own `gem_server_conformance` suite against a live gem server as `rake test:conformance`, with every example Paquette does not pass listed and explained in `test/conformance/rspec_exclusions.rb`
|
|
21
|
+
- Serve `/names` with the format's `---` marker and a terminating newline, and terminate the last line of `/versions` too
|
|
22
|
+
- Answer the compact index endpoints as `text/plain; charset=utf-8` rather than an unqualified `text/plain`, which RFC 2046 reads as US-ASCII
|
|
23
|
+
- Leave `ruby:` out of a compact index line for a gem that constrains no Ruby version, the way `rubygems:` already was, and join a multi-clause Ruby requirement with `&` rather than a comma that would read as another field; the sidecar cache format is bumped so warm caches re-derive it
|
|
24
|
+
- Stop listing a gem in `/names` once its last version has been yanked, which had `/names` announcing a gem whose `/info/` answered 404
|
|
25
|
+
- Serve conditional GETs on `/versions`, `/names` and `/info/`, with a 304 skipping the compact index render entirely
|
|
26
|
+
- Serve single byte ranges on `/versions`, `/names` and `/info/` with `Accept-Ranges` and a `Repr-Digest` of the whole document, so Bundler >= 2.5 can update its compact index by appending the tail instead of refetching it; a range this server will not serve is ignored and answered in full rather than with a 416
|
|
27
|
+
- Derive the `created_at:` of `/versions` from the oldest publication time in the corpus instead of the clock, which makes the document a pure function of the corpus, turns its `ETag` strong, and stops a ranged fetch from failing its digest check on every render
|
|
28
|
+
- Derive HTTP cache validators from the whole repository wrapper stack, and emit none at all when a layer cannot describe itself
|
|
29
|
+
- Never answer a request carrying `Authorization` or a `Cookie`, or one served through a gate or a personalizer, with `Cache-Control: public`, so a shared cache in front cannot replay an authorized download or index to a caller the embedder's gate would have refused; everything else caller-specific is `private` with `Vary: Authorization, Cookie, Accept-Encoding` (security)
|
|
30
|
+
- Answer anonymous requests over an ungated, unpersonalized repository `public`, so a CDN can cache an open registry, and add `shared_caching: false` to both servers for embedders who authorize by IP, mTLS or a header Paquette cannot see
|
|
31
|
+
- Add `gate_key:` to both `ReadGatedRepository` classes, the caller-supplied name a gate needs before it may be cached
|
|
32
|
+
- Add `cache_validator`, `varies_by_caller?` and `gem_checksum` to the repository protocols
|
|
33
|
+
- Serve `.gem` downloads and npm tarballs as immutable, with an `ETag`, `Last-Modified` and `Range` support on Rack's own file serving
|
|
34
|
+
- Answer conditional GETs on npm packuments and dist-tags, and never store `/-/whoami`
|
|
35
|
+
- Fold each `dist-tags.json` mtime into the npm fingerprint as whole nanoseconds rather than a float
|
|
36
|
+
- Document what rack-cache or Rails' Rack::Cache in front of a Paquette server does and does not store
|
|
37
|
+
- Serve only absolute http(s) URLs for `homepage` and the `*_uri` gemspec metadata keys, and for the npm packument `homepage`
|
|
38
|
+
- Name each gem's `required_rubygems_version` in the compact index, and bump the sidecar cache format so warm caches re-derive it
|
|
39
|
+
- Accept every version RubyGems publishes, not just three-segment ones — a gem pushed at `0.2` or `0.17` used to be written to disk and then served to nobody
|
|
40
|
+
- Add `CooldownRepository`, a wrapper serving only versions published longer than a configured interval ago
|
|
41
|
+
- Give `CooldownRepository` a cache validator that moves only when a version cools, a push or yank lands, or the interval changes, so its index documents can answer 304; a custom `published_at:` gets one only with `published_at_validator:`
|
|
42
|
+
- Validate a pushed gemspec before acting on it, closing an arbitrary file write through `spec.name` (security)
|
|
43
|
+
- Refuse newlines and NUL bytes in every spec field the compact index interpolates, so a push cannot forge an index row
|
|
44
|
+
- Refuse any pushed gemspec field longer than 2KB
|
|
45
|
+
- Validate the npm package version, which reached the filesystem as a path component the same way
|
|
46
|
+
- Parse an uploaded gemspec with YAML alias expansion disabled
|
|
47
|
+
- Stream a gem push to disk instead of buffering it in memory, and cap it with `max_push_bytes:` on the server — `Paquette::MAX_PUSH_SIZE_BYTES` (50MB) unless told otherwise. The cap is the server's rule and the server's enforcement: `DirectoryGemRepository#add_gem` takes the payload and nothing else
|
|
48
|
+
- Cap npm request bodies with the same `max_push_bytes:` keyword on `NpmServer` — a publish carries the whole tarball base64-encoded in JSON, and used to be read without a limit
|
|
49
|
+
|
|
50
|
+
## 0.2.0
|
|
51
|
+
|
|
52
|
+
- Repack gems in-process instead of shelling out to the `gem` binary
|
|
53
|
+
- Serve correctly when Paquette is mounted under a path prefix
|
|
54
|
+
- Autoload the library and namespace it with flat identifiers
|
|
55
|
+
- Arm the regexp backtracking ceiling from inside the apps instead of a middleware
|
|
56
|
+
- Extract `ReadonlyRepository` out of `ReadGatedRepository`
|
|
57
|
+
- Add `bin/dev`, a read-write gem-only server backed by `tmp/gems`
|
|
58
|
+
- Add an HTML placeholder page for repository roots
|
|
59
|
+
- Verify publishing OTPs, with each server owning its own dialect
|
|
60
|
+
- Add repository fingerprints, route recognition, and selective personalization
|
|
61
|
+
- Add per-request timing instrumentation, and return 400 for bodies that could not be parsed
|
|
62
|
+
- Add a full NPM registry: metadata, tarball serving, publishing, read gating, and licensee personalization
|
|
63
|
+
- Verify npm parity against a real npm client installing from and publishing to Paquette
|
|
64
|
+
- Make repacked gems and written tarballs byte-reproducible
|
|
65
|
+
- Have the compact index name each gem's dependencies, and make `/versions` checksum what `/info/` returns
|
|
66
|
+
- Build gem info lines from sidecar metadata instead of re-parsing each gem
|
|
67
|
+
- Stop the personalizer from deleting directories it did not create
|
|
68
|
+
- Add the `TokenAuthorization` Rack middleware
|
|
69
|
+
- Add a guide for hosting Paquette inside a Rails app
|
|
70
|
+
- Add a GitHub Actions CI workflow
|
|
71
|
+
|
|
72
|
+
## 0.1.0
|
|
73
|
+
|
|
74
|
+
- First packaged release: gem repository serving the compact index, `/info/`, `/versions`, and gem downloads
|
|
75
|
+
- Add the RubyGems push and yank endpoints
|
|
76
|
+
- Add read gating so a licensee is only ever handed their own packages
|
|
77
|
+
- Add the gem repacker and personalizer, injecting licensee metadata into the gemspec
|
|
78
|
+
- Route with Mustermann on top of the minuscule app framework
|
|
79
|
+
- Ship the gemspec for gem distribution
|
data/README.md
CHANGED
|
@@ -1,26 +1,129 @@
|
|
|
1
|
-
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="img/paquette-wordmark-logo-on-dark.png">
|
|
3
|
+
<img src="img/paquette-wordmark-logo.png" alt="Paquette" width="420">
|
|
4
|
+
</picture>
|
|
2
5
|
|
|
3
|
-
Paquette is a (
|
|
6
|
+
Paquette is a (slightly unhinged) Rack-based server for libraries. At the moment it serves gems and NPM packages. It is designed to be small and embeddable in other Ruby web applications. You allocate a directory with packages and expose it through Paquette, all the while bringing your own authentication, limits, filtering and entitlements.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
In essence, it is a gem/NPM server for distributing commercial packages. I use
|
|
9
|
+
it for https://shop.stanquette.nl and it is pretty neat!
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
Things are somewhat in flux but Paquette is usable alright.
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
bundle install
|
|
12
|
-
```
|
|
13
|
+
## Basic setup
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
bundle exec puma
|
|
17
|
-
```
|
|
15
|
+
This is a Ruby library so you will need Ruby installed. To try a minimal setup, do a `bundle install` and start `bin/dev`. Try pushing a gem into Paquette and then adding the local server to your `Gemfile` - and then do a `bundle install` in your application for that custom gem.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
However, that is not how Paquette is primarily meant to be used. It is meant to be integrated into a larger Rails or Rack application, and otherwise bent to your will in various (slightly unhinged) ways. Just a few use cases Paquette was designed for:
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
- Hosting your internal package registry (why get the jFrog and the kitchen sink?)
|
|
20
|
+
- Distributing your packages under specific authentication
|
|
21
|
+
- Distributing your packages with licensing
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The key Paquette concepts are:
|
|
24
|
+
|
|
25
|
+
- The serving app object - this is what poses as a gem/NPM repo. It's a Rack app.
|
|
26
|
+
- The repository object - that's the frontend for your stored packages. The basic implementation just exposes a dir.
|
|
27
|
+
- The repository wrappers - that's how you can control fulfillment from Paquette.
|
|
28
|
+
|
|
29
|
+
A very basic example: in your Rails app, define a Paquette server with gems inside your app's `storage/`:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# routes.rb
|
|
33
|
+
|
|
34
|
+
gem_dir = Rails.root.join("storage/gems")
|
|
35
|
+
gem_repo = Paquette::GemServer::DirectoryGemRepository.new(gem_dir)
|
|
36
|
+
gem_server = Paquette::GemServer.new(gem_repo)
|
|
37
|
+
|
|
38
|
+
# Both package servers require a separate hostname
|
|
39
|
+
constraints(->(req) { req.host.start_with?("gem.") }) do
|
|
40
|
+
mount gem_server, at: "/"
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Call your app with `gem.localhost:3000` (or whichever other port you use) and you should see the Paquette placeholder screen. You can then push gems into your Rails app using the `gem.localhost:3000` source URL. Setup for NPM is similar.
|
|
45
|
+
|
|
46
|
+
The `Paquette::GemServer::DirectoryGemRepository` is a repository. The `Paquette::GemServer` is the Rack app that serves a repository.
|
|
47
|
+
|
|
48
|
+
## Repository wrappers
|
|
49
|
+
|
|
50
|
+
You can make your package server do interesting things by wrapping your `repo`. For example, to disallow gem pushes:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# ...the rest as above
|
|
54
|
+
gem_repo = Paquette::GemServer::ReadonlyRepository.new(gem_repo) # forbids yank and push
|
|
55
|
+
gem_server = Paquette::GemServer.new(gem_repo)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
You can also create your own entitlement checks. This allows you to customize which gems get offered to a specific user:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# ...the rest as above
|
|
62
|
+
gem_repo = Paquette::GemServer::ReadGatedRepository.new(gem_repo, gate_key: Current.license.cache_key) do |name:, version: nil|
|
|
63
|
+
lic = Current.license
|
|
64
|
+
lic.package_names.include?(name)
|
|
65
|
+
end
|
|
66
|
+
gem_server = Paquette::GemServer.new(gem_repo)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Now the holder of the `license` stored in your `ActiveSupport::Current` for the request will only receive the gems included in their license's `package_names` list. And you can limit version access as well. This affects serving the actual packages, serving the indexes and anything else.
|
|
70
|
+
|
|
71
|
+
The `gate_key:` (the npm `ReadGatedRepository` takes one too) is optional and is only used for HTTP caching. Broadly speaking, it is the user or license ID which alters the ETag of the response. If you pass none the response won't be cacheable - see [HTTP caching](#http-caching).
|
|
72
|
+
|
|
73
|
+
### Package personalization
|
|
74
|
+
|
|
75
|
+
Paquette includes a _personalization_ wrapper which allows every package to be marked with license details of the downlaoder. See it as a "soft-DRM" in the style of Pragmatic Programmers back in the day.
|
|
76
|
+
|
|
77
|
+
> [!IMPORTANT]
|
|
78
|
+
> Personalizing a package changes its checksum, and the checksum may become unique for every license. Often, this is exactly what you want. However, if you _change_ how you personalize a package for a specific user, their package manager may detect the changed checksum and assume the package has been tampered with. So - if you do personalize, either let your users know that package checksums will change when you alter the personalization flow, or never change how packages get personalized.
|
|
79
|
+
>
|
|
80
|
+
> Personalizing packages thus has security implications.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# ...the rest as above
|
|
84
|
+
gem_repo = Paquette::GemServer::Personalizer.new(
|
|
85
|
+
gem_repo,
|
|
86
|
+
|
|
87
|
+
# Gets injected into the gemspec metadata as "paquette.license_key"
|
|
88
|
+
license_key: Current.license.serial,
|
|
89
|
+
|
|
90
|
+
# The cache's name for "who this gem is baked for" - anything the
|
|
91
|
+
# personalized contents depend on has to be part of it, or a cached
|
|
92
|
+
# artifact will be reused after the personalization has changed
|
|
93
|
+
personalization_key: Current.license.holder_name,
|
|
94
|
+
|
|
95
|
+
# Apply blanket edits to a specific magic comment in all Ruby files
|
|
96
|
+
magic_comment_replacements: {"# license: " => Current.license.serial},
|
|
97
|
+
|
|
98
|
+
# Inject files when repackaging the gem, hash of paths to contents
|
|
99
|
+
files: {"THANK-YOU.txt" => "Thank you for using this library!"}
|
|
100
|
+
)
|
|
101
|
+
gem_server = Paquette::GemServer.new(gem_repo)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
All the packages passing through personalization will be repackaged for this specific `personalization_key` and cached. The initial request for the package index may be slow if checksums and sizes have to be provided (that depends on the API endpoint and the registry used).
|
|
105
|
+
|
|
106
|
+
### Cooldown
|
|
107
|
+
|
|
108
|
+
There is a _cooldown_ wrapper, which withholds versions that were published less than a given interval ago:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# ...the rest as above
|
|
112
|
+
cooldown_seconds = 7 * 24 * 60 * 60
|
|
113
|
+
gem_repo = Paquette::GemServer::CooldownRepository.new(gem_repo, interval: cooldown_seconds)
|
|
114
|
+
gem_server = Paquette::GemServer.new(gem_repo)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A version that is still cooling does not appear in `/names`, `/versions`, `/info/`, `/specs.4.8`, `/latest_specs.4.8` or `/prerelease_specs.4.8`, and it does not download - it 404s, as if the gem version were not present.
|
|
118
|
+
|
|
119
|
+
> [!IMPORTANT]
|
|
120
|
+
> By default the publication date is `spec.date`, the date recorded in the gemspec - which is **publisher-controlled and records the build time, not the moment the gem arrived on your server.** A gem built in March and pushed in June is already past a seven-day cooldown when it lands.
|
|
121
|
+
|
|
122
|
+
The default is `spec.date` because it is a property of the immutable gem bytes.
|
|
123
|
+
|
|
124
|
+
## Wiring it all together
|
|
125
|
+
|
|
126
|
+
Since Paquette is very modular and is supposed to be assembled per use case, you will likely want to construct your own Rack application to hold the bits together:
|
|
24
127
|
|
|
25
128
|
```ruby
|
|
26
129
|
def call(env)
|
|
@@ -44,26 +147,18 @@ def call(env)
|
|
|
44
147
|
end
|
|
45
148
|
```
|
|
46
149
|
|
|
47
|
-
|
|
150
|
+
The NPM server is assembled exactly the same way, out of the same kind of parts - see [Usage for NPM packages](#usage-for-npm-packages).
|
|
48
151
|
|
|
49
|
-
|
|
50
|
-
- `ReadGatedRepository` wraps a repository and filters every name/version through the block — unauthorized gems simply stop existing as far as the server is concerned. It also refuses writes outright: if you gate reads, you are saying this caller is not the right party to mutate the corpus, so `add_gem`/`yank_gem` raise `WriteNotAllowed` (which the server turns into a 403).
|
|
51
|
-
- `Personalizer` wraps a repository and rewrites each served `.gem` on the fly to embed the user's license key.
|
|
152
|
+
## Hostname limitations
|
|
52
153
|
|
|
53
|
-
|
|
154
|
+
The server will run on `http://localhost:9292` by default. Note that the NPM registry and the RubyGems registry have to live on separate domains - so they will respond on whichever domain is passed in that has `gem.` or `npm.` as first subdomain. If your OS supports `.localhost` TLDs, you can access `gem.whatever.localhost:9292` and it will respond.
|
|
54
155
|
|
|
55
|
-
|
|
156
|
+
## Publishing gems into Paquette
|
|
56
157
|
|
|
57
|
-
|
|
158
|
+
You can publish gems two ways.
|
|
58
159
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
### Publishing gems into Paquette
|
|
62
|
-
|
|
63
|
-
You can publish gems two ways.
|
|
64
|
-
|
|
65
|
-
1. Place `.gem` files in the `gems/` directory. The filename should follow the format: `gemname-version.gem`
|
|
66
|
-
2. Actually do a `gem push` - from your shell, do `gem push --host http://gems.localhost:9292 pkg/your-gem-0.1.0.gem`
|
|
160
|
+
1. Place `.gem` files in a per-gem directory under `gems/`, as `gems/gemname/gemname-version.gem`
|
|
161
|
+
2. Actually do a `gem push` - from your shell, do `gem push --host http://gem.localhost:9292 pkg/your-gem-0.1.0.gem`
|
|
67
162
|
|
|
68
163
|
Note that Paquette will use whichever auth you wrap it with - that is to say, in the default dev setup - _none._ I told you it is slightly unhinged.
|
|
69
164
|
|
|
@@ -72,19 +167,205 @@ Note that Paquette will use whichever auth you wrap it with - that is to say, in
|
|
|
72
167
|
To install gems from Paquette, set it as source in your Gemfile - and provide auth for whichever auth mechanism you wrap it with:
|
|
73
168
|
|
|
74
169
|
```ruby
|
|
75
|
-
gem "private_algos", source: "https://tok_998218907784:x-oauth-basic@
|
|
170
|
+
gem "private_algos", source: "https://tok_998218907784:x-oauth-basic@gem.paquette.acme.com"
|
|
76
171
|
```
|
|
77
172
|
|
|
78
|
-
|
|
173
|
+
The RubyGems API in Paquette supports the following endpoints:
|
|
79
174
|
|
|
80
175
|
- `GET /` - Repository info
|
|
81
|
-
- `GET /api/v1/dependencies` - Gem dependencies
|
|
176
|
+
- `GET /api/v1/dependencies` - Gem dependencies, as Marshal
|
|
177
|
+
- `GET /api/v1/dependencies.json` - the same thing as JSON
|
|
82
178
|
- `GET /api/v1/versions` - Available gem versions
|
|
83
179
|
- `GET /api/v1/names` - Available gem names
|
|
84
180
|
- `GET /api/v1/search.json` - Search gems
|
|
181
|
+
- `GET /specs.4.8`, `GET /specs.4.8.gz` - Legacy Marshal index of every released version
|
|
182
|
+
- `GET /latest_specs.4.8`, `GET /latest_specs.4.8.gz` - The newest release of each gem
|
|
183
|
+
- `GET /prerelease_specs.4.8`, `GET /prerelease_specs.4.8.gz` - Every prerelease version
|
|
184
|
+
- `GET /names`, `GET /versions`, `GET /info/{gemname}` - Compact index
|
|
185
|
+
- `GET /quick/Marshal.4.8/{gemname-version}.gemspec.rz` - Marshalled gemspec
|
|
85
186
|
- `GET /gems/{gemname-version.gem}` - Download gem file
|
|
86
187
|
- `POST /api/v1/gems` - Upload gem (basic implementation)
|
|
188
|
+
- `GET /specs.4.8`, `GET /latest_specs.4.8` (and their `.gz` variants) - the legacy Marshal indexes
|
|
189
|
+
- `GET /names`, `GET /versions`, `GET /info/{gemname}` - the compact index
|
|
190
|
+
|
|
191
|
+
### Platform gems
|
|
192
|
+
|
|
193
|
+
A gem built for a platform is stored, downloaded and indexed under the filename RubyGems gives it - `nokogiri-1.16.0-java.gem` - so the same name and version can exist for `ruby`, `java` and `arm64-darwin` side by side, and each is downloaded and yanked on its own.
|
|
194
|
+
|
|
195
|
+
Where the platform ends up in a response depends on the format, Paquette follows Rubygems conventions.
|
|
196
|
+
|
|
197
|
+
`gem push` files each build under that same RubyGems filename, so the three builds of one release are three artifacts rather than one pushed three times. What follows from that:
|
|
198
|
+
|
|
199
|
+
### URL filtering in package metadata
|
|
200
|
+
|
|
201
|
+
Most of what that endpoints return comes straight out of the uploaded gemspec, so it is written by whoever pushed the gem. Two fields are filtered before they go out: `homepage`, and every `*_uri` key in `metadata`. Each is served only when it is an absolute `http`/`https` URL with a host, and replaced with `""` (or dropped, for a metadata key) when it is not — `gem build` only warns about a `javascript:` or `data:` homepage, so a crafted gem can carry one. The npm packument's top-level `homepage` is filtered the same way; its `repository` and `bugs` are not, because `git://` and `git+ssh://` are legitimate there, and neither are the per-version documents under `versions`, which pass `package.json` through whole.
|
|
202
|
+
|
|
203
|
+
Everything else — `authors`, `summary`, `description`, `info`, the non-URI keys of `metadata` — is passed through unchanged and is still uploader-controlled text. If you render any of it in a page of your own, escape it there.
|
|
204
|
+
|
|
205
|
+
## Usage for NPM packages
|
|
206
|
+
|
|
207
|
+
The NPM server is built the same way as the gem server and out of the same kind of parts: one repository object, wrapped in as many layers as you want, handed to a Rack app.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
def call(env)
|
|
211
|
+
username = env["REMOTE_USER"]
|
|
212
|
+
user = User.where(login: username).first!
|
|
213
|
+
packages_dir = Rails.root.join("packages", "npm").to_s
|
|
214
|
+
|
|
215
|
+
repo = Paquette::NpmServer::DirectoryNpmRepository.new(packages_dir)
|
|
216
|
+
|
|
217
|
+
repo_with_gating = Paquette::NpmServer::ReadGatedRepository.new(repo) do |name:, version: nil|
|
|
218
|
+
user.license.package_names.include?(name)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
repo_with_gating_and_personalization = Paquette::NpmServer::Personalizer.new(
|
|
222
|
+
repo_with_gating,
|
|
223
|
+
license_key: user.license_key,
|
|
224
|
+
magic_comment_replacements: {"// paquette_license_info" => user.license_key},
|
|
225
|
+
files: {"LICENSE.txt" => user.rendered_license}
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
Paquette::NpmServer.new(repo_with_gating_and_personalization).call(env)
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The layers mean the same things they do for gems, with a few quirks:
|
|
233
|
+
|
|
234
|
+
- A gated `latest` follows the newest version the caller is entitled to, not the newest version that exists. Otherwise `npm install pkg` would resolve to a version the very next request refuses to serve.
|
|
235
|
+
- `latest` never points at a prerelease while a stable release exists, which is what npm's own registry does.
|
|
236
|
+
|
|
237
|
+
### Where packages live on disk
|
|
238
|
+
|
|
239
|
+
One directory per package, with a scope as an ordinary directory above it:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
packages/npm/lodash/lodash-4.17.21.tgz
|
|
243
|
+
packages/npm/@acme/widgets/widgets-1.0.0.tgz
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The scope stays on the package name but is dropped from the filename, exactly as in the tarball URLs npm follows (`/@acme/widgets/-/widgets-1.0.0.tgz`).
|
|
247
|
+
|
|
248
|
+
### Personalization and integrity with NPM packages
|
|
249
|
+
|
|
250
|
+
npm records a `dist.integrity` hash for every version and refuses to install a tarball whose bytes do not match. A registry that rebuilds a tarball to serve it therefore has to rebuild it to *the same bytes* it published a hash for. Thus the same restriction applies as for Rubygems packages.
|
|
251
|
+
|
|
252
|
+
Magic comment replacements forces replacements to be on one line. This is done so that if your package contains sourcemaps the offsets of the sourcemap do not shift - and the sourcemap thus won't have to be rewritten.
|
|
253
|
+
|
|
254
|
+
### Publishing NPM packages into Paquette
|
|
255
|
+
|
|
256
|
+
1. Place `.tgz` files in the layout above. The filename should be `name-version.tgz`, scope excluded.
|
|
257
|
+
2. Actually do an `npm publish` - from your shell, do `npm publish --registry http://npm.localhost:9292`.
|
|
258
|
+
|
|
259
|
+
`npm unpublish` works too. An unpublished version leaves a `.tgz.tomb` behind, which stops that exact version being republished with different contents later - the same trick the gem side uses for yanks.
|
|
260
|
+
|
|
261
|
+
### Consuming packages provided by a Paquette server
|
|
262
|
+
|
|
263
|
+
Point npm at it and provide auth for whichever mechanism you wrapped it with:
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
# .npmrc
|
|
267
|
+
@acme:registry=https://npm.paquette.acme.com
|
|
268
|
+
//npm.paquette.acme.com/:_authToken=tok_998218907784
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### NPM API Endpoints
|
|
272
|
+
|
|
273
|
+
- `GET /` - Repository info
|
|
274
|
+
- `GET /-/ping` - Liveness
|
|
275
|
+
- `GET /-/whoami` - The identity your auth wrapper resolved
|
|
276
|
+
- `GET /{package}` - Package metadata document
|
|
277
|
+
- `GET /{package}/-/{name-version.tgz}` - Download tarball
|
|
278
|
+
- `GET /-/package/{package}/dist-tags` - Read dist-tags
|
|
279
|
+
- `PUT /-/package/{package}/dist-tags/{tag}` - Point a dist-tag at a version
|
|
280
|
+
- `PUT /{package}` - Publish
|
|
281
|
+
- `PUT /{package}/-rev/{rev}` - Document update (how `npm unpublish` removes versions)
|
|
282
|
+
- `DELETE /{package}/-rev/{rev}` - Unpublish a package
|
|
283
|
+
- `DELETE /{package}/-/{name-version.tgz}/-rev/{rev}` - Unpublish one version
|
|
284
|
+
|
|
285
|
+
## HTTP caching
|
|
286
|
+
|
|
287
|
+
Paquette does not cache anything itself. It emits correct HTTP cache headers and answers conditional requests, and leaves the actual caching to a cache you put in front of it. I usually use `Rack::Cache` which is fairly reliable. You can put a CDN in front of Paquette if you know that it handles the `Vary: Authorization` bits correctly.
|
|
288
|
+
|
|
289
|
+
Paquette will try to emit sensible ETags for most endpoints, and there is support for partial responses.
|
|
290
|
+
|
|
291
|
+
### Putting a cache in front
|
|
292
|
+
|
|
293
|
+
Under plain Rack, with [rack-cache](https://github.com/rtomayko/rack-cache):
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# config.ru
|
|
297
|
+
require "rack/cache"
|
|
298
|
+
|
|
299
|
+
use Rack::Cache,
|
|
300
|
+
metastore: "file:tmp/cache/rack/meta",
|
|
301
|
+
entitystore: "file:tmp/cache/rack/body",
|
|
302
|
+
verbose: false
|
|
303
|
+
|
|
304
|
+
run Paquette::GemServer.new(gem_repo)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Under Rails, `config.action_dispatch.rack_cache = true` does the same. Put your authorization *below* the cache, next to Paquette, so it runs on every request the cache does not answer.
|
|
308
|
+
|
|
309
|
+
On an open registry, anonymous downloads are then served out of the cache and index documents are revalidated against Paquette with a cheap 304. Authorized, gated and personalized responses are never stored; each of those requests reaches your gate and Paquette every time, and the client's own cache still revalidates cheaply. If the server-side cost of those is what you are after, cache inside your application - the personalizer already keeps repacked artifacts on disk.
|
|
310
|
+
|
|
311
|
+
### Caching validators
|
|
312
|
+
|
|
313
|
+
Paquette allows the repo wrappers to alter the validator (the ETag). The validator itself is derived from the whole wrapper stack, not just from the corpus, wrappers are written to contribute to the ETag in a meaningful way.
|
|
314
|
+
|
|
315
|
+
A `nil` validator propagates outward, so a `Personalizer` wrapped around a keyless gate emits no `ETag` either.
|
|
316
|
+
|
|
317
|
+
## The index page
|
|
318
|
+
|
|
319
|
+
You can customize the user-visible index page of your package server by supplying a Rack app that will serve it, like so:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
# Serve our index page
|
|
323
|
+
Paquette::GemServer.new(repo, placeholder_app: Paquette::IndexPage.new("Gems for Stanquette staff. Ask Julik for a token."))
|
|
324
|
+
|
|
325
|
+
# ...or a redirect
|
|
326
|
+
Paquette::NpmServer.new(repo, placeholder_app: ->(_env) { [302, {"location" => "https://docs.example.com"}, []] })
|
|
327
|
+
Paquette::NpmServer.new(repo, placeholder_app: ->(_env) { [404, {}, []] }) # no root at all
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Mounting under a path prefix (namespaces)
|
|
331
|
+
|
|
332
|
+
Both servers can be mounted under a prefix, which is how [gem.coop spells a namespace](https://gem.coop/updates/6/). In Rack only a `map` and nothing else:
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
Rack::Builder.new do
|
|
336
|
+
map("/@acme") { run Paquette::GemServer.new(acme_repo) }
|
|
337
|
+
map("/@beta") { run Paquette::GemServer.new(beta_repo) }
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
A namespace per repository is also a namespace per wrapper stack: each mount can have its own gating and personalization, since it is its own `GemServer` around its own repository.
|
|
342
|
+
|
|
343
|
+
## Instrumentation
|
|
344
|
+
|
|
345
|
+
Everything expensive in Paquette is wrapped in a [Measurometer](https://rubygems.org/gems/measurometer) block, so that you know how long things take. Measurometer does nothing until a driver is attached, so you may need to configure it:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
Measurometer.drivers << Appsignal
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Metric paths that Paquette outputs are all prefixed with `paquette.` - the rest should be fairly self-explanatory.
|
|
352
|
+
|
|
353
|
+
## Running the tests
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
bundle exec rake test
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Most of it is ordinary Ruby, but we also have a few end-to-end tests, that require Docker and Node. You don't need either for using Paquette though.
|
|
360
|
+
|
|
361
|
+
RubyGems' own compact-index conformance suite runs separately, because it shells out to the `gem_server_conformance` RSpec binary:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
bundle exec rake test:conformance
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
It boots a gem server over a bare `DirectoryGemRepository` and drives the suite against it over HTTP. `test/conformance/rspec_exclusions.rb` lists the examples Paquette knowingly does not pass and why — read it before concluding that a green run means full conformance. Without the `gem_server_conformance` gem installed the test skips; set `PAQUETTE_REQUIRE_CONFORMANCE=1` to turn that skip into a failure, which is what CI wants.
|
|
87
368
|
|
|
88
369
|
## License
|
|
89
370
|
|
|
90
|
-
Paquette is offered under the terms of the [O'Sassy license](https://osaasy.dev/) - basically, don't make it into your own product
|
|
371
|
+
Paquette is offered under the terms of the [O'Sassy license](https://osaasy.dev/) - basically, **don't make it into your own product or a service.** Use it to sell your libraries. And godspeed!
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
# How a repository stack names itself to an HTTP cache. Both servers wrap a
|
|
4
|
+
# base repository in per-caller gates and personalizers, so a validator
|
|
5
|
+
# derived from the corpus alone would let one licensee's cached response
|
|
6
|
+
# satisfy another's — the digest rules live here, once, for both servers.
|
|
7
|
+
module Paquette::CacheValidation
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# The stack's validator. Fails closed (nil) for an object that has never
|
|
11
|
+
# heard of the protocol.
|
|
12
|
+
#
|
|
13
|
+
# @param repository [Object]
|
|
14
|
+
# @return [String, nil]
|
|
15
|
+
def validator_of(repository)
|
|
16
|
+
repository.cache_validator if repository.respond_to?(:cache_validator)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Whether two callers asking the stack the same question can get different
|
|
20
|
+
# answers — which decides whether an anonymous response may be `public`.
|
|
21
|
+
# True for anything that cannot say: being wrong the other way only costs
|
|
22
|
+
# a cache hit.
|
|
23
|
+
#
|
|
24
|
+
# @param repository [Object]
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def varies_by_caller?(repository)
|
|
27
|
+
return true unless repository.respond_to?(:varies_by_caller?)
|
|
28
|
+
|
|
29
|
+
repository.varies_by_caller? != false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# How a wrapper mixes itself into the validator of what it wraps. nil in
|
|
33
|
+
# either position gives nil out — the fail-closed rule the whole protocol
|
|
34
|
+
# rests on. The layer name is in the digest so that gating with key "k"
|
|
35
|
+
# and personalizing with key "k" cannot land on the same string.
|
|
36
|
+
#
|
|
37
|
+
# @param inner_validator [String, nil] the wrapped stack's validator
|
|
38
|
+
# @param layer [String] a constant naming the wrapping layer
|
|
39
|
+
# @param key [String, nil] what distinguishes this instance of the layer
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def derive_validator(inner_validator, layer, key)
|
|
42
|
+
return nil if inner_validator.nil? || key.nil?
|
|
43
|
+
|
|
44
|
+
Digest::SHA256.base64digest([inner_validator, layer, key].join("\0"))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The validator for one response. Returns nil, meaning "emit no ETag",
|
|
48
|
+
# whenever the stack refused to name itself.
|
|
49
|
+
#
|
|
50
|
+
# @param repository [Object]
|
|
51
|
+
# @param resource_parts [Array<String>] what distinguishes this endpoint's
|
|
52
|
+
# body from another's over the same corpus
|
|
53
|
+
# @param weak [Boolean]
|
|
54
|
+
# @return [String, nil]
|
|
55
|
+
def etag_for(repository, *resource_parts, weak: false)
|
|
56
|
+
base = validator_of(repository)
|
|
57
|
+
return nil if base.nil?
|
|
58
|
+
|
|
59
|
+
tag = %("#{Digest::SHA256.base64digest([base, *resource_parts].join("\0"))}")
|
|
60
|
+
weak ? "W/#{tag}" : tag
|
|
61
|
+
end
|
|
62
|
+
end
|