feat(npm): npmremote addressing and upstream request building
🎯 What this MR does
Adds the addressing vocabulary and the upstream request composition seam the npm remote proxy handlers and the scheduled health sweep will share: Target/TargetKind, the opaque path encoding, and UpstreamRequestBuilder with its per-class fetch options. Step 2a of the 5-MR stack splitting an oversized branch — see the table below. A follow-up MR (2b) adds the cache-table seam and health-sweep target resolution to this package.
dmeshcharakou/s15-step-9-mr1-npm-datastore-health; GitLab retargets to main when !1481 (merged) merges.
| Step | MR | What it delivers |
|---|---|---|
| 0 | !1479 (merged) | Shared refsWithUsableURL enumeration helper |
| 1 | !1481 (merged) | npm datastore health seams |
| 2a · this MR | npmremote.Target + UpstreamRequestBuilder (this MR) |
|
| 2b | !1483 (merged) | npmremote.CacheStore + health-sweep glue |
| 3 | !1484 (merged) | Composition root: wiring, audit sink, breadcrumb |
🧩 The two pieces
1️⃣ Target and the opaque path encoding
Target's kind travels in the path because a flight is keyed by path alone: a full and an abbreviated packument sharing one would coalesce into a single upstream fetch and cache one document as both. Path and ParseTargetPath round-trip, and the decode unescapes each segment before validating it, so a percent-encoded traversal is judged as the byte it decodes to.
2️⃣ UpstreamRequestBuilder and fetch options
UpstreamRequestBuilder holds no URL or credential state: every BuildRequest call reads the remote's current base URL and credentials through the UpstreamConfigSource seam and revalidates the URL. One long-lived builder per repository can therefore sit under remote.SingleFlight, whose contract requires one long-lived instance per repository, and a URL or credential write reaches the next request. The builder joins the URL, attaches the bearer only when the remote has a token (a stored token no HTTP header can carry is refused with ErrUpstreamAuthToken), sets the Accept header naming the metadata variant, and pins Accept-Encoding: identity on every request, matching the maven and OCI builders. MetadataFetchOptions/TarballFetchOptions carry the per-class options: the metadata class carries the streaming rewrite and the npm cap override, and the tarball class opts into the wider cap explicitly, since the metadata class is the fail-small default.
Note on the split: testPackageName and requireNoAuthorizationLeak are shared test fixtures used by this MR's suites and MR2b's, so they land here (in a new testsupport_test.go) since this part ships first. Target.tarballVersion() stays out of this MR even though it is logically a Target method — its only callers live in CacheStore (MR2b) and it would otherwise show as unused in this MR standalone; MR2b introduces it alongside its callers.
✅ Verification
- Full package suite passes with none of MR2b's code present: 79 test functions + 3 fuzz seed corpora, all green,
-raceincluded. The review-driven guards fail their tests when removed, checked by reverting each: lossless path encode, header-safe bearer, and the per-call configuration read (a builder mutated to memoize its first source read fails the composed update test and the per-call unit test). singleflight_composition_test.gocomposes one long-livedremote.SingleFlightover one long-lived builder: concurrent cold fetches for one path issue exactly one upstream GET, and a URL rotation, token rotation, and token clear each reach the next fetch.go build ./...,go vet ./...: clean.golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 ./internal/format/npm/npmremote/... ./internal/format/npm/: clean (0 issues after relocatingtarballVersion, which theunusedlinter correctly flagged with only this MR's code present).
🧪 E2E scenario impact
None: no HTTP handler wires this package yet. The npm packument/dist-tags/tarball handlers land in later steps of the parent plan.
Related to #346 (closed)