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.

⚠️ Stacked: targets dmeshcharakou/s15-step-9-mr1-npm-datastore-health; GitLab retargets to main when !1481 (merged) merges.

📚 The stack:

Step MR What it delivers
0 !1479 (merged) Shared refsWithUsableURL enumeration helper
1 !1481 (merged) npm datastore health seams
2a · this MR 👉 !1482 (merged) 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, -race included. 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.go composes one long-lived remote.SingleFlight over 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 relocating tarballVersion, which the unused linter 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)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading