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.
| 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 | 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_timeoutbounds a proxied tarball end to end. S13 exempts an artifact-body Fetch and specifiestransfer_idle_timeoutinstead, but no such setting exists in the schema or the client and the deadline is unconditional. Because the relay is client-paced, the shipped30sis 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's422. 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 fullgo test ./internal/... ./cmd/...: clean.go test -race -count=2over 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 frommain.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.orgthroughseed-npm-remote: cold relay200with a declaredContent-Length,Vary: Authorization,Cache-Control: private; warm serve carryingETag: "sha256-6a087ac9…"; conditionalGETanswering304with 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 bothsuccessandfile_not_found, and the completion line carriednamespace_id,repository_id,package_name, andpackage_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)