feat(npm): mount the remote tarball proxy on the dispatcher

🎯 What this MR does

Wires the pieces from 12a–12c into the composition root, so the kind=2 npm tarball route stops answering the interim 501 and starts proxying. Step 12d of a 4-MR stack, and the only one that changes what a client sees.

⚠️ This is the behavior change. Merge order: 12a → 12b → 12c → 12d.

📚 The stack:

Step MR What it delivers
12a !1685 (merged) npmremote.FlightRegistry + npm.RemoteOperationsProvider
12b !1686 (merged) npm.RemoteTarballHandler + the response-contract suite
12c !1687 (closed) The read-path suite + instrumenting the remote routes
12d · this MR 👉 !1688 (closed) Composition root: mount, integration suite, spec, plan, recipe

🧩 The four pieces

1️⃣ One registry per process, one provider per slot

buildNpmRemoteSlots builds the registry once per process rather than once per handler, and one provider serves every slot it returns. That is what will keep the tarball, packument, and dist-tags routes of one repository on one flight once all three are wired; only the tarball slot is returned today. Steps 10 and 11 append their options here rather than building a composition root of their own, which is why the plan now makes them dependents of Step 12 instead of its siblings.

The cache stores are built once and shared because they are stateless query seams. What must not be shared is npmremote.CacheStore, which captures a repository's two validity windows at construction; the provider rebuilds it per request.

2️⃣ An unwired composition keeps the 501

npmRemoteWiring's zero value wires no remote read path, so the DB-less unit stub and the partial boots keep every remote route on its interim 501 rather than nil-panicking on the first kind=2 request. The discriminator is the upstream client, tested before upstreamhttp.NewDoer wraps it: NewDoer would wrap a nil client in a non-nil Doer that reads as wired and then fails on the first request. A wired composition that cannot build its operations panics at boot instead, because serving the 501 there would make a broken rollout read as a feature that is merely unimplemented.

3️⃣ The integration suite

It lands here rather than with the handler because it is the DB-observable half, and it proves against a real database and BlobStore what the wiring above makes reachable: the rows a fill writes, the pinned-window serve, conditional revalidation on the wire, the blob repoint when an upstream re-publishes a version, and both retention anchors.

4️⃣ Spec, plan, and the run recipe

No management API can create a kind=remote repository: create.go rejects any kind but hosted with a 422 until the remote-repository CRUD ships. seed-npm-remote therefore inserts the four rows directly, the same way seed inserts the namespace, and dies naming the seed command when the slug has no namespace rather than reporting a repository the anchored CTE never created.

S15 records what the implementation settled: the validators, the per-shape cache headers, the relay framing, the transfer time bound, and the two error classes the proxy adds. The plan records that this step landed the shared composition root, corrects Steps 10 and 11 to point at buildNpmRemoteSlots rather than wire_npm.go, and states the merge order they now depend on.

📌 Two things flagged rather than fixed

  • request_total_timeout bounds a proxied tarball end to end. S13 exempts an artifact-body Fetch and specifies transfer_idle_timeout instead, but no such setting exists in the schema or the client and the deadline is unconditional. Because the relay is client-paced, the shipped 30s is an end-to-end ceiling on the download, and a fetch it cuts off commits no cache row, so the request fails the same way on retry. The gap is S13-owned and predates this proxy, which is the second consumer of an artifact-body Fetch after Maven remote. S15 now states the operator precondition and points at #269.
  • The route is unreachable in production until the remote-repository CRUD ships, per create.go's 422. That bounds this stack's blast radius to zero today, and is why the run recipe is currently the only way to exercise it.

Verification

  • go build ./..., go vet ./..., and the full go test ./internal/... ./cmd/...: clean.
  • go test -race -count=2 over the npm, metrics, and composition-root packages: green.
  • golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0: 0 findings on any line this stack adds or changes. The pre-existing findings it reports elsewhere in those packages are unchanged from main.
  • mise run conformance:npm: passed end to end against the real npm CLI (publish scoped and unscoped, view, install, dist-tag, deprecate, both unpublish flows, name reuse after unpublish).
  • driver.sh smoke: 22 passed, 0 failed.
  • Live proxy run against registry.npmjs.org through seed-npm-remote: cold relay 200 with a declared Content-Length, Vary: Authorization, Cache-Control: private; warm serve carrying ETag: "sha256-6a087ac9…"; conditional GET answering 304 with no body; relayed bytes byte-identical to the cached bytes and to the content address in the validator. npm_request_total{handler="remote_download"} recorded both success and file_not_found, and the completion line carried namespace_id, repository_id, package_name, and package_version.

📏 Diff size

1509 LOC, past the 500 reviewable-LOC line in docs/dev/development-model.md. Split by file group:

Group Files LOC
Production wire_npm_remote.go 168, wire.go/wire_npm.go/wire_root_dispatcher.go +18 186
Tests remote_tarball_integration_test.go 808, wire_npm_remote_test.go 166, five call-site test files +11 985
Docs S15-npm-remote.md +103, 2026-07-15-npm-remote.md +119, e2e/npm.md +9 231
Tooling driver.sh +94, SKILL.md +13 107

The spec, the plan, and the run recipe are 338 of it, and guardrails 12 and 20 require them in the same MR as the behavior change.

🧪 E2E scenario impact

docs/testing/e2e/npm.md is updated. No scenario is added: npm install begins with the packument, and a repository whose packument route is still the interim 501 fails there whatever its tarball route does. An npm install journey becomes addable once Step 10 lands, which is what the plan gates its end-to-end step on; dist-tags is needed only for a scenario that calls that endpoint itself, since a pkg@tag install resolves the tag client-side from the packument.

Related to #349 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading