feat(npm): mount the remote packument proxy from the composition root
🎯 What this MR does
Builds the shared npm remote proxy in wireServices and threads it through mountSlugAnchoredFormats, so the dispatcher's kind=remote packument slot serves the real handler instead of the interim 501. Step 10d of a 4-MR stack.
This is the MR that changes behavior. GET /{slug}/npm/{repo}/{package_name} on a kind=remote repository stops answering 501 and starts proxying.
| Step | MR | What it delivers |
|---|---|---|
| 10a | !1681 (closed) | npm.RemoteProxy: one coalescing flight per remote repository, plus the shared test harness |
| 10b | !1682 (merged) | npm.RemotePackumentHandler: variant selection, the two serve shapes, the response headers |
| 10c | !1683 (merged) | The error mapping, and the upstream_rejected code |
| 10d · this MR | Composition root: mount, run recipe |
Merge order: 10a, 10b, 10c, then this. Each MR calls symbols the one before it introduces, so none compiles against main on its own. The plan's Step 10 entry states the order too, because a stack can be merged out of order and the plan is what survives the branches.
🧩 The two pieces
1️⃣ One proxy per process, built from the tiers already wired
buildNpmRemoteProxy composes the remote cache-table stores, the remote-repository row reader (which also answers the durable health status the fetch gate reads), the shared BlobStore, and the shared upstream HTTP client, plus the npm and resolution knobs that bound one metadata fetch and the coalescing layer. One proxy is built per process and shared across every remote npm repository, because it holds the one remote.SingleFlight each of them requires.
It travels to mountSlugAnchoredFormats as a parameter rather than being built there, because it needs the shared upstream HTTP client, which the remote tier owns.
The store takes the production audit sink for the reason buildNpmResolver's construction does: the constructor requires one for the credential-writing methods the same type carries, and a stub here would be the construction a later credential-writing consumer inherits an inert trail from. This construction reads the remote row and its health status; it writes nothing.
2️⃣ Degrade to 501 rather than fail boot
buildNpmRemoteProxy returns an error instead of panicking when a prerequisite is missing, and buildNpmRemoteProxyOrSkip logs it and returns nil, which leaves the remote read slots on their interim 501. That is the same leave-it-unwired posture the async packument-cache rebuild already takes on the same unset value.
The prerequisite that can actually be absent is npm.public_registry_url. Every rewritten dist.tarball in a proxied packument is built from it and then cached, so an unset base URL would persist unusable URLs rather than degrade a single response. wireRemote aborts the boot when the upstream client cannot be built, so the nil-doer guard names a wiring order that changed rather than a runtime condition.
The absent upstream client is left as a nil interface rather than boxed: upstreamhttp.NewDoer wraps even a nil client into a non-nil *Doer, which would clear buildNpmRemoteProxy's own nil check and nil-dereference on the first proxied request instead.
The skip is logged for the reason logOCIAuthzWiring's is: nothing else signals that a configured remote repository is answering 501 rather than proxying, so a dropped Helm value would be silent.
Run recipe
Guardrail 21's companion change. The driver omits npm.public_registry_url in its default ephemeral mode, because it cannot represent an ephemeral port, so kind=remote npm reads answer 501 there. The gotcha sits next to the existing npm install one, which turns on the same key for the same reason, and the troubleshooting row is keyed on the 501 a reader actually sees rather than on a log line they would have to know to look for.
Verified rather than asserted: against a clean driver.sh up, the generated config carries no npm: stanza and ar.log holds npm remote proxy not wired: kind=remote npm reads serve the interim 501. A first check looked like the line was missing; the run had reused an already-live instance whose boot predated the log.
✅ Verification
gofmt -l,go build ./...,go vet ./...with and without-tags=integration: clean.go test -count=1 ./internal/format/npm/... ./internal/config/... ./cmd/artifact-registry/: green.go test -tags=integration -run 'TestWireNPM|TestBuildNpm|TestMountSlugAnchoredFormats|TestBuildRootDispatcher' ./cmd/artifact-registry/: green, 113s.go test -tags=integration -run 'TestRemoteProxy|TestRemotePackument' ./internal/format/npm/: green, 43s.golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 ./cmd/...: 0 findings on any line this MR adds or changes, checked by mapping each finding against the MR's own hunks. The package total drops from 10 onmainto 9, because the branch incidentally fixes a pre-existingwslfinding.driver.sh smoke: 22 passed, 0 failed.
📏 Diff size
454 LOC, under the 500 reviewable-LOC line. Split by file group:
| Group | Files | LOC |
|---|---|---|
| Production | wire_npm.go +118, wire.go +41, wire_root_dispatcher.go +9 |
168 |
| Tests | wire_npm_test.go +136, wire_npm_boot_integration_test.go +126, and four boot suites at +2 or +3 each |
271 |
| Run recipe | .claude/skills/run-artifact-registry/SKILL.md +15 |
15 |
🧪 E2E scenario impact
docs/testing/e2e/npm.md still scopes remote npm out under "Out of scope until the capability ships", and this MR ships one third of it: the packument read. Dist-tags and tarball reads keep their 501 until Steps 11 and 12. Adding a catalog entry for a capability a client cannot yet install through would describe a scenario that does not run end to end, so the entry belongs with Step 12, and Step 14 owns the conformance flow that exercises it.
Related to #347 (closed)