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.gois forced: its parity trio runs both ways, so aRouteTable()row with nonpmRouteActionOracleentry is red and an oracle entry reaching no registered route is red too.internal/format/npm/npmroute/route_disagreements.mdcarries 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_AttestationsReadIsPolicyCheckedasserted the bare mux miss and now asserts the 501. The S01-envelope test becameTestNpmAuthzComposition_AttestationsInterimResponseSurvivesTheInterceptorand now pins the layer boundary:ResponseInterceptorMiddlewarerewrites 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
handlerat 16 andcodeat 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.0too. - The 501 stub stays uninstrumented.
WithAttestationsHandlercarries theinstrument(handlerAttestationsGet, npmCommandAudit, ...)wrap, so the label first emits once a real handler is injected. A handler wired through it can never logpackage_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
requireHostedSlotswould 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 falsifiedDispatchOption'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_commandgroups by parent command, which is why three unpublish routes all reportunpublish. - Chosen: one GET row on
{package_version}dispatching by kind, with the hosted slot defaulted inbuildMuxbeside 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.ValidateVersionruns 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
handlerorcodecardinality 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