docs(spec): define HEAD on npm's read routes, Step 15 (S08 remote, batch 5/12)

Summary

Related to #47 (closed).

S06 HEAD amendment per the S08 remote goal-run's annex §7 MR clusters ("S06 HEAD amendment (spec MR)"). Lands Step 15. Specification only, no code and no tests.

Targets main directly. Independent of the Band A MRs (!258 (merged), !259 (merged)): the plan gives Step 15 Depends on: nothing. It is a prerequisite for Step 16 and therefore for every npm remote row, so it lands before the npm band's first code MR.

Annex §7 keeps this a separate MR under otherwise aggressive batching, and the reason is worth restating: folding a spec amendment into the code that relies on it means the amendment gets reviewed as an implementation detail of that code.

The gap

S06 defined no HEAD operation for npm at all. Meanwhile S08 §Read surface per format lists HEAD as available on both npm read routes, and npm.remote.write-refused-publish asserts the refused write carries Allow: GET, HEAD. That Allow cited a contract S06 did not have, so there was nothing for the assertion to be checked against.

What changed in S06

  • §HTTP surface: the Method slot of Operation: Packument fetch and Operation: Tarball download widens from GET to GET, HEAD. Operation: Tarball download's prose is scoped to the GET, because the HEAD's URL is suite-built rather than a dist.tarball value.
  • §HTTP surface gains a HEAD on the read routes subsection defining the semantics: resolves as the corresponding GET with the body omitted, same path template, same request headers, same success and error statuses. On a success response the headers asserted are Content-Type and, when the response declares one, Content-Length; parity beyond those two is deliberately not asserted, because RFC 9110 §9.3.2 states it as a SHOULD and lets a server omit fields whose value is determined only while generating the content. On an error response no header parity is asserted at all, only the status and AC #7 (closed)'s empty ResponseBody. On the tarball route the one-hop redirect replay preserves the method, so the probe stays a HEAD. Availability and scope are kept apart: RFC 9110 §9.1 makes HEAD available on every route, and this spec asserts it on these two only, claiming nothing either way about the rest. Source: protocol-mandated; RFC 9110 §9.1 for availability and §9.3.2 for response semantics, corroborated per route by AR S15 (§Tarball proxy for the tarball half, the publish PUT's Allow: GET, HEAD for the packument half).
  • §Tarball-URL SSRF: the allow-list is re-keyed on the URL's provenance rather than on the route or the verb. It polices any request against a packument-supplied URL and the Location of any hop such a request follows. It does not cover the vetted-URL pair, whose URL the suite builds itself from a configured base.
  • §Redirect scheme downgrade on the vetted-URL pair (new): the normative rule that stands in the allow-list's place. A hop whose target scheme is http, where the request it came from was issued over https, MUST be refused, before any host consideration and whether or not the hop stays on the same host. Carries its error shape (*HTTPError, StatusCode: 0), a fourth §Client error cause, and AC #15 (closed). The text is sourced from #49 (closed)'s plan Step 16 rather than re-derived. It also records the residual risk plainly: the hop's host is unvalidated, and GetTarball is not the protected comparison, because GetTarball applies the allow-list but compares no schemes.
  • §pkg/client/npm method surface: declares HeadPackument(ctx, name, accept) and HeadTarballAtVettedURL(ctx, tarballURL), both returning (*HeadResponse, error), plus GetTarballAtVettedURL(ctx, tarballURL). The AtVettedURL name carries the caller's precondition in the identifier rather than in a doc comment, matching the naming table in #49 (closed)'s plan, which previously named only the GET half that way. All three land in Step 16, not here.
  • §Client types: adds HeadResponse{StatusCode, ContentType, ContentLength}, following maven.ArtifactResponse and oci.ManifestResponse. StatusCode is what makes AC #14 (closed)'s same-status clause assertable at all: §Status-code policy admits any 2xx, so without it a registry answering 200 to the GET and 204 to the HEAD passed.
  • §Status-code policy: HeadTarballAtVettedURL and GetTarballAtVettedURL join GetTarball and AuditBulk as operations that override the caller's host-locked CheckRedirect at the method level, so the count goes from two to four.
  • §Acceptance Criteria gains AC #14 (closed) and AC #15 (closed), both stating plainly that no test asserts them today. AC #14 (closed) also states what "the same" is measured against per route: the tarball route has a Content-Length comparand (len(BuildTarball(...)), a length the suite knows independently), and the packument route has none, which the criterion records rather than narrows. The AC preamble records the exception to "verifiable by at least one unit or integration test", and AC #2 (closed), AC #3 (closed) and AC #7 (closed) each say that the part of them ranging over the two Head… methods lands with that band.
  • §Open Questions (was None.) gains two: --allow-redirect-host does not reach the manual one-hop paths, which is a pre-existing client gap reaching GetTarball too and is being filed as a follow-up; and the packument route's missing Content-Length comparand, which would take widening GetPackument's return shape.
  • §Resolutions records why the hosted contract widens with no hosted row asserting it, and the alternative that was rejected.

§Out of Scope is deliberately untouched: main already carries the split, with a Remote-registry bullet naming npm.remote.cache-ttl and npm.remote.cache-invalidation as S08's. Verified rather than assumed.

Why not narrow S08 instead

The cheaper-looking fix was to narrow npm.remote.write-refused-publish's Allow to GET and leave S06 alone. Rejected, and the reason is recorded in S06 §Resolutions: the Allow value is not the suite's to choose. It reports what the repository under test serves, and a remote npm repository that answers HEAD on its read routes carries HEAD in that header whatever this spec says. Narrowing the assertion would have made the suite fail a correct implementation, which is the failure mode the whole project exists to avoid.

Defining the operation in S06 rather than S08 follows from the same reasoning: a remote repository is a proxy of the same protocol surface, so it cannot define an operation its hosted counterpart lacks. The definition belongs where the protocol is defined, even though the assertion consuming it lives in S08 and no hosted row asserts it.

What changed in S08

Two prose sentences in §Read surface per format stated the missing-HEAD gap in the present tense, and this MR is what makes them false; both now read as closed, and the AR S15 corroboration there is split by route the same way S06's is. The §Downstream amendments row keeps its imperative present tense, per that table's own preamble rule that a row's tense says nothing about whether its edit has landed; its Owner cell moves from Per-format child to !260, and its anchor and Edit cell widen to cover what the amendment actually ships (the client surface, the downgrade rule, AC #15 (closed)). The preamble gains one sentence placing !260 under conventions.md's same-MR check, since it is a per-format child MR carrying an S08 edit in this diff and the existing two-way split covered neither case.

That is wider than the plan's Files list for this step, which names only the Owner cell. Leaving a spec asserting something this same MR falsifies seemed the worse trade. The row is de-staled rather than preserved because docs/dev/conventions.md §Amendment tables' supersession rule covers an earlier row that a later MR revises, not the row under review.

What changed in the plan

#49 (closed)'s plan is kept in step with the spec, not extended: Step 16's file list gains HeadResponse and the (*HeadResponse, error) returns, Step 15's Acceptance names both new criteria instead of one, and the naming table's HeadTarball becomes HeadTarballAtVettedURL so the row agrees with the convention it states in its own Notes cell.

Test plan

  • pre-commit run --files docs/specs/S06-npm-contracts.md docs/specs/S08-remote-contracts.md docs/plans/2026-08-21-remote-conformance.md: every hook passes.
  • Every string quoted from RFC 9110 checked verbatim against the published RFC text, after review found three quotations that did not appear in it and one that rendered §9.3.2's SHOULD as a MUST.
  • AR S15 checked on main for the HEAD corroboration: its endpoint table gives the packument path GET alone and HEAD appears nowhere in §Packument proxy, so the tarball and packument halves are cited to different S15 sections.
  • The three client facts the downgrade rule rests on checked against pkg/client/npm: newRequest applies the credential unconditionally, redirectClient() replaces CheckRedirect with ErrUseLastResponse, and validateRedirectTarget compares no schemes.
  • Enumerated every HEAD, HeadTarball, Tarball-URL SSRF and downgrade occurrence across S06, S08 and the plan, per this project's "enumerate, don't eyeball" rule for spec edits, rather than reviewing the diff in isolation.
  • No code, no tests, no catalog rows. Tests: none; specification only per the plan.

Process

This MR came from a goal run whose annex §7 defines its scope and keeps this amendment separate from the code that depends on it. Judgement calls are recorded in validation/decisions.md rather than resolved silently.

Edited by Sylvia Shen

Merge request reports

Loading
Loading