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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +315 -34
  4. data/lib/paquette/cache_validation.rb +62 -0
  5. data/lib/paquette/conditional_get.rb +371 -0
  6. data/lib/paquette/gem_server/cooldown_repository.rb +186 -0
  7. data/lib/paquette/gem_server/directory_gem_repository.rb +449 -115
  8. data/lib/paquette/gem_server/gem_repacker/build_time.rb +40 -0
  9. data/lib/paquette/gem_server/gem_repacker/rooted_package.rb +65 -0
  10. data/lib/paquette/gem_server/gem_repacker.rb +166 -139
  11. data/lib/paquette/gem_server/gem_repository.rb +219 -47
  12. data/lib/paquette/gem_server/personalizer.rb +279 -43
  13. data/lib/paquette/gem_server/read_gated_repository.rb +101 -58
  14. data/lib/paquette/gem_server/readonly_repository.rb +20 -0
  15. data/lib/paquette/gem_server/spec_validator.rb +203 -0
  16. data/lib/paquette/gem_server.rb +758 -272
  17. data/lib/paquette/index_page/masthead.svg +4 -0
  18. data/lib/paquette/index_page/page.html +133 -0
  19. data/lib/paquette/index_page.rb +67 -0
  20. data/lib/paquette/npm_repacker.rb +199 -121
  21. data/lib/paquette/npm_server/directory_npm_repository.rb +438 -104
  22. data/lib/paquette/npm_server/npm_repository.rb +251 -38
  23. data/lib/paquette/npm_server/personalizer.rb +207 -0
  24. data/lib/paquette/npm_server/read_gated_repository.rb +185 -0
  25. data/lib/paquette/npm_server.rb +565 -71
  26. data/lib/paquette/otp_gate.rb +49 -0
  27. data/lib/paquette/regexp_timeout.rb +86 -0
  28. data/lib/paquette/routes.rb +216 -51
  29. data/lib/paquette/safe_url.rb +32 -0
  30. data/lib/paquette/subdomain_router.rb +50 -38
  31. data/lib/paquette/tarball.rb +312 -0
  32. data/lib/paquette/token_authorization.rb +82 -0
  33. data/lib/paquette/version.rb +2 -1
  34. data/lib/paquette.rb +47 -10
  35. data/rbi/paquette.rbi +3773 -0
  36. data/sig/paquette.rbs +3280 -0
  37. data/test/conformance/gem_server_conformance_test.rb +152 -0
  38. data/test/conformance/rspec_exclusions.rb +112 -0
  39. data/test/docker/Dockerfile +11 -0
  40. data/test/gem_server/bundler_dependency_resolution_test.rb +129 -0
  41. data/test/gem_server/bundler_install_test.rb +54 -66
  42. data/test/gem_server/compact_index_benchmark_test.rb +272 -0
  43. data/test/gem_server/compact_index_ranges_test.rb +344 -0
  44. data/test/gem_server/cooldown_repository_test.rb +638 -0
  45. data/test/gem_server/directory_gem_repository_test.rb +485 -0
  46. data/test/gem_server/gem_repacker_test.rb +143 -7
  47. data/test/gem_server/gem_repository_test.rb +192 -0
  48. data/test/gem_server/hostile_gemspec_urls_test.rb +116 -0
  49. data/test/gem_server/http_caching_test.rb +683 -0
  50. data/test/gem_server/namespace_mount_test.rb +203 -0
  51. data/test/gem_server/otp_dialect_test.rb +37 -0
  52. data/test/gem_server/personalizer_selective_test.rb +124 -0
  53. data/test/gem_server/personalizer_test.rb +193 -0
  54. data/test/gem_server/prerelease_specs_test.rb +208 -0
  55. data/test/gem_server/read_gated_repository_test.rb +3 -3
  56. data/test/gem_server/readonly_repository_test.rb +108 -0
  57. data/test/gem_server/spec_validator_test.rb +0 -0
  58. data/test/gem_server/version_shapes_test.rb +178 -0
  59. data/test/gem_server_test.rb +212 -11
  60. data/test/index_page_test.rb +48 -0
  61. data/test/instrumentation_test.rb +186 -0
  62. data/test/npm_repacker_test.rb +184 -70
  63. data/test/npm_server/directory_npm_repository_test.rb +271 -0
  64. data/test/npm_server/docker_client_test.rb +312 -0
  65. data/test/npm_server/http_caching_test.rb +526 -0
  66. data/test/npm_server/namespace_mount_test.rb +86 -0
  67. data/test/npm_server/npm_install_test.rb +144 -0
  68. data/test/npm_server/npm_server_test.rb +339 -0
  69. data/test/npm_server/otp_dialect_test.rb +54 -0
  70. data/test/npm_server/personalizer_selective_test.rb +87 -0
  71. data/test/npm_server/personalizer_test.rb +184 -0
  72. data/test/npm_server/read_gated_repository_test.rb +107 -0
  73. data/test/npm_server/regression_test.rb +224 -0
  74. data/test/otp_gate_test.rb +68 -0
  75. data/test/platform_gems_test.rb +763 -0
  76. data/test/rack_cache_test.rb +176 -0
  77. data/test/regexp_fuzz_test.rb +216 -0
  78. data/test/regexp_linearity_test.rb +418 -0
  79. data/test/regexp_timeout_test.rb +168 -0
  80. data/test/repository_fingerprint_test.rb +52 -0
  81. data/test/route_match_budget_test.rb +113 -0
  82. data/test/route_recognition_test.rb +78 -0
  83. data/test/routes_test.rb +44 -0
  84. data/test/safe_url_test.rb +77 -0
  85. data/test/subdomain_router_integration_test.rb +4 -4
  86. data/test/subdomain_router_test.rb +14 -4
  87. data/test/tarball_test.rb +134 -0
  88. data/test/test_helper.rb +301 -0
  89. data/test/token_authorization_test.rb +88 -0
  90. metadata +82 -8
  91. data/lib/paquette/npm_server/gated_npm_repository.rb +0 -66
  92. data/test/npm_repacker_sourcemap_test.rb +0 -108
  93. data/test/npm_server_test.rb +0 -140
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d8ed39d45d99f0d96e971b61bd47529362b0efa35f9b2704134628b2001bfa3
4
- data.tar.gz: 765118b95e910e0cac4b34b8ddf841471237ef93c87fc5f59a5b62e45b470a04
3
+ metadata.gz: d2e249bb3b01d7ddc7e3b78968579b004ea9ff8862895f51cdd40f814e5ab80f
4
+ data.tar.gz: f7c8e608ad032792fdecfaf2b66e5f0ac975932ce028b3cfdf5066c758401799
5
5
  SHA512:
6
- metadata.gz: fbd2e25eecb61a6c5e6cc2dba5bce5283a434fd65bfd65ef175f813c3d683342b42ed3d3f7f634943ea4b818df4f096a160a8d0a2dda3923e37e5b0f9d57a1c0
7
- data.tar.gz: 6e90af21e71e67d05f2ff3f89a26cbb65913a1ce196e62cc03d31e1d003a11411c5d0ce8feb9ebb97c64fc72a0311315ee41f83a2f32c22224a7500e116e5484
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
- # Paquette
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 (sligtly unhinged) Rack-based server for libraries. At the moment it serves gems and NPM packages. It is very basic and is made to serve packages gated by a licensing mechanism, which is supposed to be BYO.
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
- Things are very in flux at the moment, but it may come in handy.
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
- ## Setup
11
+ Things are somewhat in flux but Paquette is usable alright.
8
12
 
9
- 1. Install dependencies:
10
- ```bash
11
- bundle install
12
- ```
13
+ ## Basic setup
13
14
 
14
- 2. Start the server:
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
- ## Repository gating
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
- Paquette is built on the premise that you can have a corpus of libraries you offer, and deduce - from the `Authorization` HTTP header or by other means - which packages a user may download. Only the packages they have access to get included in the API responses - version lists, checksum lists and so on.
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
- `Paquette::GemServer` itself is dumb: it takes one repository object and routes every read, push, and yank through it. You build the stack per request by nesting plain constructors — no DSL, no callbacks registered on the server, just wrappers wrapping wrappers. For example, inside a Rack endpoint:
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
- Each line adds one capability:
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
- - `DirectoryGemRepository` reads `.gem` files from disk and accepts pushes and yanks.
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
- Because the wrappers are plain Ruby objects composed at the call site, per-user state (the `user` variable) is captured by ordinary closures. Drop a wrapper to disable that layer; add another by slotting in one more constructor. Whether the server will accept pushes and yanks is decided by what you build — wrap the base repo in `ReadGatedRepository` and writes are blocked; hand the server a bare `DirectoryGemRepository` (or your own wrapper that permits writes) and they go through.
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
- 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 `gems.` or `npm.` as first subdomain. If your OS supports `.localhost` TLDs, you can access `gems.whatever.localhost:9292` and it will respond.
156
+ ## Publishing gems into Paquette
56
157
 
57
- ## Usage for gems
158
+ You can publish gems two ways.
58
159
 
59
- Paquette contains a gem server. This is a separate Rack app which you can use without the NPM server, for example - inside of your Rails app. You can interact with it using `gem` commands, as if it were any other gem server, or by just placing stuff on the filesystem.
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@gems.paquette.acme.com"
170
+ gem "private_algos", source: "https://tok_998218907784:x-oauth-basic@gem.paquette.acme.com"
76
171
  ```
77
172
 
78
- ### API Endpoints
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. Use it to sell your libraries. And godspeed!
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