Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"path"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// routeIDLength is the number of hex characters of the upstream URL digest
|
|
|
|
|
// that qualify cache entries of a routed package.
|
|
|
|
|
const routeIDLength = 16
|
|
|
|
|
|
|
|
|
|
// packageRoutes sends packages whose names match a pattern to a dedicated
|
|
|
|
|
// upstream instead of the ecosystem's default registry. A matched package is
|
|
|
|
|
// never looked up anywhere else, so a public package with the same name cannot
|
|
|
|
|
// replace it.
|
|
|
|
|
type packageRoutes struct {
|
|
|
|
|
routes []packageRoute // longest pattern first
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type packageRoute struct {
|
|
|
|
|
pattern string
|
|
|
|
|
url string
|
|
|
|
|
id string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newPackageRoutes(patterns map[string]string) packageRoutes {
|
|
|
|
|
routes := make([]packageRoute, 0, len(patterns))
|
|
|
|
|
for pattern, upstreamURL := range patterns {
|
|
|
|
|
upstreamURL = strings.TrimSuffix(upstreamURL, "/")
|
|
|
|
|
routes = append(routes, packageRoute{
|
|
|
|
|
pattern: strings.ToLower(pattern),
|
|
|
|
|
url: upstreamURL,
|
2026-10-22 16:00:58 +02:00
|
|
|
id: routeID(upstreamURL),
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(routes, func(i, j int) bool {
|
|
|
|
|
if len(routes[i].pattern) != len(routes[j].pattern) {
|
|
|
|
|
return len(routes[i].pattern) > len(routes[j].pattern)
|
|
|
|
|
}
|
|
|
|
|
return routes[i].pattern < routes[j].pattern
|
|
|
|
|
})
|
|
|
|
|
return packageRoutes{routes: routes}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-22 16:00:58 +02:00
|
|
|
// routeID identifies a route's upstream in cache keys and filenames.
|
|
|
|
|
func routeID(upstreamURL string) string {
|
|
|
|
|
digest := sha256.Sum256([]byte(strings.TrimSuffix(upstreamURL, "/")))
|
|
|
|
|
return hex.EncodeToString(digest[:])[:routeIDLength]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RoutedFilenamePrefix returns what the npm and Composer handlers put in
|
|
|
|
|
// front of the artifact filenames of a package routed to routeURL.
|
|
|
|
|
func RoutedFilenamePrefix(routeURL string) string {
|
|
|
|
|
return routeID(routeURL) + "/"
|
|
|
|
|
}
|
|
|
|
|
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
// match returns the route for a package name, if any pattern matches it.
|
|
|
|
|
func (r packageRoutes) match(packageName string) (packageRoute, bool) {
|
|
|
|
|
name := strings.ToLower(packageName)
|
|
|
|
|
for _, route := range r.routes {
|
|
|
|
|
if ok, _ := path.Match(route.pattern, name); ok {
|
|
|
|
|
return route, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return packageRoute{}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cacheKey qualifies a package's metadata cache key with the route's upstream,
|
|
|
|
|
// so entries cached from the default registry, or from a route's previous
|
|
|
|
|
// upstream, are never served for a routed package.
|
|
|
|
|
func (r packageRoute) cacheKey(packageName string) string {
|
|
|
|
|
return "routes/" + r.id + "/" + packageName
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cacheFilename qualifies a routed package's artifact filename the same way as
|
|
|
|
|
// cacheKey, and leaves other packages' filenames unchanged.
|
|
|
|
|
func (r packageRoutes) cacheFilename(packageName, filename string) string {
|
|
|
|
|
if route, routed := r.match(packageName); routed {
|
|
|
|
|
return route.id + "/" + filename
|
|
|
|
|
}
|
|
|
|
|
return filename
|
|
|
|
|
}
|
2026-10-22 16:00:58 +02:00
|
|
|
|
|
|
|
|
// RoutedPackageURL returns the upstream URL a route in routes sends
|
|
|
|
|
// packageName to, as the npm and Composer handlers pick it.
|
|
|
|
|
func RoutedPackageURL(routes map[string]string, packageName string) (string, bool) {
|
|
|
|
|
route, ok := newPackageRoutes(routes).match(packageName)
|
|
|
|
|
return route.url, ok
|
|
|
|
|
}
|