chore(npm): mount the attestations route on an interim 501 slot (S11 Provenance Attestations plan: 10/20)

Why

npm audit signatures fetches a version's provenance bundle from GET /{slug}/npm/{repository_name}/-/npm/v1/attestations/{package_name}@{version}. chore(npm): attestations route grammar and auth... (!2807 - closed) • Hayley Swimelar • 19.5 taught npmroute that path and mapped it to read_artifact, but no dispatcher row registers it. An authorized read pays a namespace lookup, a repository lookup, an IAM read, and a GLAZ check, then falls through to the mux's own miss, which ResponseInterceptorMiddleware hands back as an S01 404. To every caller the route still looks like one that does not exist.

This mounts it on the interim 501 that every unwired slot already serves. Only hosted gets a slot, and it stays empty until Step 18 wires Step 16's handler, so hosted, remote, and virtual all answer 501 today.

Depends on: chore(npm): attestations route grammar and auth... (!2807 - closed) • Hayley Swimelar • 19.5, and merges after it. Step 6 widened the authorization predicate ahead of this dispatcher row, and denials_total{denial_reason="unparsed_route"} stopped counting the path when it did. This MR closes that window. The S09 route-to-action row now lands with Step 6 rather than here, so the parity trio below reads a table the target branch already carries.

What

  • Two files land here that the plan's Step 10 Files list does not name. cmd/artifact-registry/wire_npm_authz_test.go is forced: its parity trio runs both ways, so a RouteTable() row with no npmRouteActionOracle entry is red and an oracle entry reaching no registered route is red too. internal/format/npm/npmroute/route_disagreements.md carries the mount's wildcard census.
  • That census was wrong, and this MR corrects it to eight. It read five, omitting {rev} and {tag}, which the document's own shape 1 reverse family and shape 2 both turn on. {package_version} is the eighth and adds no new shape: nothing else can match a path under /-/npm/v1/attestations/, and a decoded name the mux cannot take as one segment misses the mux table rather than reaching a second route.
  • Two tests Step 6 landed pinned the fall-through, so they are re-aimed here. TestNpmAuthzComposition_AttestationsReadIsPolicyChecked asserted the bare mux miss and now asserts the 501. The S01-envelope test became TestNpmAuthzComposition_AttestationsInterimResponseSurvivesTheInterceptor and now pins the layer boundary: ResponseInterceptorMiddleware rewrites 404 and 405 only, so a 501 leaves the dispatcher untouched. Both were mutation-tested rather than read. The denial-masking test is unchanged, because authz denies above the dispatcher and never reaches the row.
  • No cardinality pin moves. Step 1 seeded handler at 16 and code at 46 ahead of the first emission, so this route is what those numbers already counted. RouteTable()'s own pin moves 15 to 16. The plan says "14 to 15", written before Step 1's row landed.
  • One segment, not a {tail...} sibling row. S11 fixes the path as one whole segment with the scope percent-encoded, so the single {package_version} wildcard catches @acme%2Fwidget@1.0.0 too.
  • The 501 stub stays uninstrumented. WithAttestationsHandler carries the instrument(handlerAttestationsGet, npmCommandAudit, ...) wrap, so the label first emits once a real handler is injected. A handler wired through it can never log package_name, because the route's only wildcard is {package_version}.

Test plan

Behavior Test
501 for hosted, remote, and virtual, on GET and HEAD, unscoped and percent-encoded scoped TestDispatchHandler_AttestationsRoute_Answers501ForEveryKind
Only hosted has a slot, so a wired hosted handler never leaks to remote or virtual TestDispatchHandler_AttestationsSlot_ServesHostedOnly
The row dispatches by kind rather than serving a bare stub TestDispatchHandler_AttestationsUnknownKind_Returns500
A name the mux cannot take as one segment misses the mux table rather than reaching a second route TestDispatchHandler_AttestationsUnmatchableName_ReachesNoRoute
WithAttestationsHandler wraps its handler under handlerAttestationsGet and npmCommandAudit TestWithAttestationsHandler_InstrumentsTheInjectedHandler
WithAttestationsHandler(nil) panics at option construction TestWithAttestationsHandler_PanicsOnNil
The route-count pin is 16 and every row reaches a handler TestRouteTable_MatchesBuiltMux
The registered row parses under npmroute and drives read_artifact on GET and HEAD TestNpmRouteTableParity_RegisteredRoutesMatchSpecActions
Every route S09 names is reachable by its own method TestNpmRouteTableParity_SpecRoutesAreAllReachable
The registered and S09-named (method, route) sets are exactly equal TestNpmRouteTableParity_RegisteredAndSpecRouteSetsMatch
An authorized reader reaches the row's 501, not the masked 404 TestNpmAuthzComposition_AttestationsReadIsPolicyChecked
The 501 leaves the dispatcher unrewritten TestNpmAuthzComposition_AttestationsInterimResponseSurvivesTheInterceptor
A denied read still masks byte-identically to a denied packument read TestNpmAuthzComposition_AttestationsDenialMasksLikeAReadRoute (unchanged)

Rig. driver.sh smoke on an isolated slot: 74 passed, 0 failed. An authenticated GET and HEAD on /{slug}/npm/{repo}/-/npm/v1/attestations/lodash@1.0.0 answer 501 not_implemented in npm's envelope against a hosted, a remote, and a virtual repository, and so does the percent-encoded scoped form @acme%2Fwidget@1.0.0. The write verbs answer 405 with Allow: GET, HEAD. A decoded @acme/widget@1.0.0 misses the mux table and returns the S01 404, which is the containment claim route_disagreements.md makes. After roughly 40 such requests the process had minted no npm_request_total{handler="attestations_get"} series, which is the uninstrumented-stub claim both metrics documents make.

Guardrails

No docs/testing/ row flips: e2e.npm.publish.provenance-attestations-endpoint already covers this route and stays blocked on the publish path this MR does not touch. One caveat for the later lander: that page's intro says the blocked rows wait because "no bundle is stored and no attestations route is mounted", and the second conjunct goes stale when this merges. The plan assigns that intro to Steps 15, 18, and 19 under its later-lander rule, so this branch leaves it.

api/openapi/** and api/bruno/** are untouched, because the management-API contract carries no npm protocol routes. No docs/user/** page moves: nothing reaches this URL until a later step emits dist.attestations.url, and the npm CLI reads that field rather than building the URL itself. ADR 009's enumerative npm route list omits this route, which S11 already records as a gap with the handbook amendment deferred, and ADR 021's read_artifact row covers it unchanged from Step 6. The diff is 359 reviewable LOC, under the 500 threshold.

Context for LLM agents

Rationale

  • Register the row and fill the hosted slot with a real handler now. Rejected: Step 16 owns that handler and it does not exist yet, and requireHostedSlots would panic at boot.
  • Keep orInterimStub(h.attestationsGet) inside the route row, as the plan words it. Rejected: buildMux's defaulting pass is the single place a reader sees which slots are optional, and leaving the tenth field out of it falsified DispatchOption's own doc comment.
  • Add a {tail...} sibling row so an unescaped scoped path reaches the mux. Rejected: S11 fixes the path as one whole segment with the scope percent-encoded, so the sibling would claim paths the contract does not define.
  • Name the option's command label audit-signatures. Rejected: npm_command groups by parent command, which is why three unpublish routes all report unpublish.
  • Chosen: one GET row on {package_version} dispatching by kind, with the hosted slot defaulted in buildMux beside the remote and virtual ones.

Consequences: the route answers 501 for every kind until Step 18, and handler="attestations_get" stays unemitted until then, which is what Step 1's pre-seeded cardinality budget already assumes.

Non-goals

  • Serving the bundle. Step 16 writes the handler and Step 18 wires it.
  • Validating ParsedRoute.Version. npmrules.ValidateVersion runs in the handler step, before any use of the capture. The 501 slot reads no path value.
  • Amending ADR 009's npm route list. The gap is recorded beside the route's contract in S11 and the amendment is deferred past this plan.
  • Moving the handler or code cardinality pins. Step 1 seeded both ahead of the first emission.
  • Rewriting docs/testing/e2e/npm.md's intro. Its later-lander rule gives that to Steps 15, 18, and 19.

Related to #1273

Edited by Hayley Swimelar

Merge request reports

Loading
Loading