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)
- feat(npm): kind-dispatch contract and write 405... (!1313 - merged) • David Fernandez • 19.3
👈 - feat(npm): any-kind resolution and remote wirin... (!1314 - merged) • David Fernandez, Dzmitry (Dima) Meshcharakou • 19.3
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:
ResolutiongainsKindandRemote(additive: the resolver keeps its hosted-only lookup and assignsKind=hostedexplicitly,Remote=nil— part 2 turns any-kind resolution on).dispatchByKindwraps 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=remoterepository returns405withcode: "unsupported"and the exactAllowheader the spec fixes (publishPUTadvertisesGET, 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*Handleroptions and default to the interim 501 stub; each proxy handler will flip only its own slot. buildMuxrefuses to build a route table with an unfilled hosted slot, so a route added tonpmRouteHandlersand wired in only one constructor fails at boot rather than on first traffic.transportgains theunsupportedandupstream_unavailablecodes with safe messages;isServerErrorCodeclassifiesupstream_unavailableas a server outcome.- Usage data maps the emitted repository kind from
Resolution.Kind(usageKindsmap + totality test) instead of asserting hosted by construction. - The publish-envelope sniffer moves out of
handler.gointoenvelope_sniff.go(code motion only), so the kind-dispatch seam lands with no net growth inhandler.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
413precedes the405for an over-cap declared body.server.BodySizeMiddlewaresits ahead of the npm dispatcher, so a publish whoseContent-Lengthexceedsserver.max_body_sizeis answered413 request_entity_too_largeand never reaches kind dispatch; a chunked publish declares no length, passes the upfront check, and gets the405. The split is by how the client sent the body, not by repository kind, and hosted publishes have always behaved this way. Pinned byTestDispatchHandler_OversizePublish_413PrecedesRemote405.- The remote-write
405is deliberately uncounted. It is written outside the instrument seam, andrequestCodeLabels()enumerates S11's Error Cases plus success, sounsupportedis outside the request metric's declaredcodedomain — 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 thenpm_remote_proxywide event) and defines no write-rejection metric, so counting rejections is a spec change rather than a label minted here. upstream_unavailablejoins the metric domain with its emitter. The proxy step that instruments a remote handler adds the code torequestCodeLabels()in the same change, so the declaredcodedomain 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)