feat(npm): kind-dispatch contract and write 405s (S15 Step 8, part 1/2)

Split into 2 stacked MRs to keep each part within the review size limit (the full step is ~2,400 changed lines). Each part targets the previous one (part 1 targets main); review and merge bottom-up.

Stacked MRs (review/merge bottom-up)

Part 1 of 2 of docs/plans/2026-07-15-npm-remote.md — Step 8: kind-dispatch seam + write-unavailability. Spec: docs/specs/S15-npm-remote.md.

🎯 What this part does

Lands the npm kind-dispatch route table and its client-visible contracts, while production resolution stays hosted-only:

  • Resolution gains Kind and Remote (additive: the resolver keeps its hosted-only lookup and assigns Kind=hosted explicitly, Remote=nil — part 2 turns any-kind resolution on).
  • dispatchByKind wraps every format route: hosted arm, per-route remote behavior, virtual-kind interim 501, out-of-domain kind fails loud as a logged 500.
  • Every write verb on a kind=remote repository returns 405 with code: "unsupported" and the exact Allow header the spec fixes (publish PUT advertises GET, HEAD, the read verbs the packument path serves; the write-only dist-tag mutation and -rev/{rev} routes send the RFC 9110 present-but-empty form), decided before the publish body sniff reads a byte.
  • Remote read slots (packument, dist-tags, tarball) are injectable through WithRemote*Handler options and default to the interim 501 stub; each proxy handler will flip only its own slot.
  • buildMux refuses to build a route table with an unfilled hosted slot, so a route added to npmRouteHandlers and wired in only one constructor fails at boot rather than on first traffic.
  • transport gains the unsupported and upstream_unavailable codes with safe messages; isServerErrorCode classifies upstream_unavailable as a server outcome.
  • Usage data maps the emitted repository kind from Resolution.Kind (usageKinds map + totality test) instead of asserting hosted by construction.
  • The publish-envelope sniffer moves out of handler.go into envelope_sniff.go (code motion only), so the kind-dispatch seam lands with no net growth in handler.go.

🔍 Spec coverage

Spec: docs/specs/S15-npm-remote.md

Acceptance criteria

# Criterion Tests
Write unavailability 1 Publish rejected: PUT .../{package} on a kind=2 repository returns 405, code: "unsupported", Allow: GET, HEAD TestDispatchHandler_RemoteWriteVerbs_Return405Unsupported, TestDispatchHandler_BaseDispatcher_RemoteWritesStill405
Write unavailability 2 Dist-tag mutation rejected: PUT .../dist-tags/{tag} returns 405, code: "unsupported", empty Allow TestDispatchHandler_RemoteWriteVerbs_Return405Unsupported
Write unavailability 3 Unpublish routes rejected: each -rev/{rev} route returns 405, code: "unsupported", empty Allow TestDispatchHandler_RemoteWriteVerbs_Return405Unsupported, TestDispatchHandler_BaseDispatcher_RemoteWritesStill405
Packument proxy (all) Remote packument and dist-tags reads Owned by the proxy steps. Not implemented here: each remote read slot serves the interim 501, pinned by TestDispatchHandler_RemoteReadRoutes_DefaultTo501, and delegates once wired, pinned by TestDispatchHandler_RemoteReadSlots_DelegateWhenWired.
Tarball proxy (all) Remote tarball reads Owned by the proxy steps. Same interim-501 and slot-delegation coverage as above.
Credentials and health (all) Bearer attach, redaction, credential clearing, health probe Owned by part 2 and the credential/health steps. No upstream request is issued in this part.
Error mapping (all) Upstream 404, transport-failure fallback, 5xx propagation Owned by the proxy steps. This part only declares the codes and classifies upstream_unavailable (TestIsServerErrorCode).

Error cases

Condition Tests
Write verb on a remote repository: 405 unsupported with the exact Allow per route, zero body bytes read TestDispatchHandler_RemoteWriteVerbs_Return405Unsupported
Suspended-namespace write: 403 precedes the remote 405 TestDispatchHandler_SuspendedNamespaceWrite_403PrecedesRemote405
kind=virtual on every kind-dispatched route: interim 501 TestDispatchHandler_VirtualKind_Returns501Everywhere
Kind outside the repositories.kind CHECK domain: logged 500, never a fall-through TestDispatchHandler_UnknownKind_Returns500
Kind dispatch reached without the Middleware (no Resolution in context): logged 500, neither arm runs TestDispatchByKind_MissingResolution_Returns500
upstream_unavailable is a server outcome for the request log level TestIsServerErrorCode
Upstream 404/5xx propagation, 503 with Retry-After, cache fallback Owned by the proxy steps. Not tested in this MR — no upstream request path exists yet.

Security considerations

Concern Tests
Write-authorization boundary: a remote repository never accepts a write, decided from the Resolution before any body byte is read TestDispatchHandler_RemoteWriteVerbs_Return405Unsupported (asserts zero body bytes read), TestDispatchHandler_BaseDispatcher_RemoteWritesStill405
A wiring bug cannot fail open into a kind's behavior TestBuildMux_NilHostedSlot_PanicsAtConstruction, TestDispatchByKind_MissingResolution_Returns500, TestDispatchHandler_UnknownKind_Returns500, TestWithRemoteHandlerOptions_PanicOnNil
Credential hygiene, credentials at rest, SSRF, cross-origin redirect token stripping, outbound path-segment safety, error-payload hygiene, tarball-URL rewrite Owned by the S13 upstream client and the proxy steps. Not implemented in this part.

Seam contracts with no S15 criterion of their own

Contract Tests
Hosted routes are unaffected by wired remote slots TestDispatchHandler_HostedRoutesUnaffectedByRemoteSlots
Audit and search stubs stay 404 for every kind TestDispatchHandler_AuditSearchStubs_KindAgnostic
usageKinds covers every repository kind, and an unmapped kind withholds the event with a skip log carrying repository_kind_code TestUsageKinds_CoversEveryRepositoryKind, TestDownload_UnmappedKind_EmitsNoEventAndLogs
Resolver.Resolve assigns Kind rather than inheriting the int16 zero TestResolver_Resolve_Success

The kind-dispatch suite drives kinds through fake resolvers, so the contract is fully covered while production traffic is byte-for-byte unaffected (the pre-existing hosted suites double as the regression floor).

📈 Observability and the edges of the 405 contract

  • 413 precedes the 405 for an over-cap declared body. server.BodySizeMiddleware sits ahead of the npm dispatcher, so a publish whose Content-Length exceeds server.max_body_size is answered 413 request_entity_too_large and never reaches kind dispatch; a chunked publish declares no length, passes the upfront check, and gets the 405. The split is by how the client sent the body, not by repository kind, and hosted publishes have always behaved this way. Pinned by TestDispatchHandler_OversizePublish_413PrecedesRemote405.
  • The remote-write 405 is deliberately uncounted. It is written outside the instrument seam, and requestCodeLabels() enumerates S11's Error Cases plus success, so unsupported is outside the request metric's declared code domain — an attempted write on a remote repository shows up in the access log only. S15's observability surface is proxy-scoped (upstream responses, cache events, transform duration, cache-fill bytes, and the npm_remote_proxy wide event) and defines no write-rejection metric, so counting rejections is a spec change rather than a label minted here.
  • upstream_unavailable joins the metric domain with its emitter. The proxy step that instruments a remote handler adds the code to requestCodeLabels() in the same change, so the declared code domain and what runtime can emit stay in agreement.

📖 E2E scenario catalogs

No docs/testing/ update: no npm e2e catalog exists yet (docs/testing/e2e/ holds docker and oci only). The remote-read 501s are interim, and the 405 write-unavailability contract lands in the npm-remote catalog together with the proxy handlers.

Related to #345 (closed)

Edited by David Fernandez

Merge request reports

Loading
Loading