diff --git a/config.example.yaml b/config.example.yaml index 0a74554..ceb659a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -103,6 +103,10 @@ upstream: # npm registry URL npm: "https://registry.npmjs.org" + # Always request full npm packuments so served metadata carries publish + # times ("time" map) even when cooldown is disabled. Needed by clients that + # gate on publish age, e.g. Yarn's npmMinimalAgeGate. Default: false. + # npm_full_metadata: true # Cargo sparse index URL cargo: "https://index.crates.io" diff --git a/docs/configuration.md b/docs/configuration.md index de88498..5cdc359 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -141,6 +141,7 @@ Each upstream used by a built-in package route can be set in YAML or JSON under | `upstream.allow_private_hosts` | `PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS` | `[]` | | `upstream.allow_loopback` | `PROXY_UPSTREAM_ALLOW_LOOPBACK` | `false` | | `upstream.npm` | `PROXY_UPSTREAM_NPM` | `https://registry.npmjs.org` | +| `upstream.npm_full_metadata` | `PROXY_UPSTREAM_NPM_FULL_METADATA` | `false` | | `upstream.cargo` | `PROXY_UPSTREAM_CARGO` | `https://index.crates.io` | | `upstream.cargo_download` | `PROXY_UPSTREAM_CARGO_DOWNLOAD` | `https://static.crates.io/crates` | | `upstream.gem` | `PROXY_UPSTREAM_GEM` | `https://rubygems.org` | diff --git a/internal/config/config.go b/internal/config/config.go index 2fbf64f..523ef47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -316,6 +316,13 @@ type UpstreamConfig struct { // Default: https://registry.npmjs.org NPM string `json:"npm" yaml:"npm"` + // NPMFullMetadata always requests the full packument (application/json) + // from the npm upstream, so served metadata carries the "time" map even + // when cooldown is disabled. Clients that gate on publish age (for + // example Yarn's npmMinimalAgeGate) need this. + // Default: false (the abbreviated format is preferred). + NPMFullMetadata bool `json:"npm_full_metadata" yaml:"npm_full_metadata"` + // Cargo is the upstream cargo index URL. // Default: https://index.crates.io Cargo string `json:"cargo" yaml:"cargo"` @@ -687,6 +694,7 @@ func (c *Config) LoadFromEnv() { setEnvStringSlice(&c.Upstream.AllowPrivateHosts, "PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS") setEnvBool(&c.Upstream.AllowLoopback, "PROXY_UPSTREAM_ALLOW_LOOPBACK") setEnvString(&c.Upstream.NPM, "PROXY_UPSTREAM_NPM") + setEnvBool(&c.Upstream.NPMFullMetadata, "PROXY_UPSTREAM_NPM_FULL_METADATA") setEnvString(&c.Upstream.Cargo, "PROXY_UPSTREAM_CARGO") setEnvString(&c.Upstream.CargoDownload, "PROXY_UPSTREAM_CARGO_DOWNLOAD") setEnvString(&c.Upstream.Gem, "PROXY_UPSTREAM_GEM") diff --git a/internal/handler/handler.go b/internal/handler/handler.go index a4f75bf..82f1153 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -112,8 +112,11 @@ type Proxy struct { MetadataMaxSize int64 GradleReadOnly bool GradleMaxUploadSize int64 - DirectServe bool - DirectServeTTL time.Duration + // NPMFullMetadata requests full npm packuments from upstream even when + // cooldown is disabled, so served metadata carries publish times. + NPMFullMetadata bool + DirectServe bool + DirectServeTTL time.Duration // DirectServeBaseURL, if set, replaces the scheme and host of presigned // URLs so clients receive a public address even when the proxy reaches // storage at an internal one. diff --git a/internal/handler/npm.go b/internal/handler/npm.go index b7d96a3..9a1c73a 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -76,8 +76,10 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques // Artifactory, which returns 406) can still respond with full metadata. // When cooldown is enabled we must use full metadata exclusively because the // abbreviated format omits the "time" map required for version age filtering. + // Operators can also force full metadata so clients that gate on publish + // age (for example Yarn's npmMinimalAgeGate) keep working through the proxy. accept := npmAcceptDefault - if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() { + if h.proxy.NPMFullMetadata || (h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()) { accept = contentTypeJSON } diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go index 07da9c3..e8c2513 100644 --- a/internal/handler/npm_test.go +++ b/internal/handler/npm_test.go @@ -430,6 +430,25 @@ func TestNPMHandlerUsesAbbreviatedMetadata(t *testing.T) { t.Errorf("Accept = %q, want %q (cooldown requires full metadata)", gotAccept, contentTypeJSON) } }) + + t.Run("full metadata option uses full metadata without cooldown", func(t *testing.T) { + proxy := testProxy() + proxy.NPMFullMetadata = true + + h := &NPMHandler{ + proxy: proxy, + upstreamURL: upstream.URL, + proxyURL: "http://proxy.local", + } + + req := httptest.NewRequest(http.MethodGet, "/testpkg", nil) + w := httptest.NewRecorder() + h.handlePackageMetadata(w, req) + + if gotAccept != contentTypeJSON { + t.Errorf("Accept = %q, want %q (npm_full_metadata requires full metadata)", gotAccept, contentTypeJSON) + } + }) } func TestNPMHandlerMetadataNotFound(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 541be1c..e2c892a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -227,6 +227,7 @@ func (s *Server) serve(listener net.Listener) error { proxy.MetadataTTL = s.cfg.ParseMetadataTTL() proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize() proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly + proxy.NPMFullMetadata = s.cfg.Upstream.NPMFullMetadata proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize() proxy.DirectServe = s.cfg.Storage.DirectServe proxy.DirectServeTTL = s.cfg.ParseDirectServeTTL()