S09 enforcement 13/20: npm wiring and route-table parity
Step 13 of 20 of the [S09 enforcement plan](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-08-21-s09-authorization-enforcement.md#step-13-npm-wiring-and-route-table-parity).
## :dart: Scope
Wrap the npm dispatcher at its catch-all registration, add npm's resolution holder, export the registered route table, and pin it against the grammar. Also carries the step 11 leftover: move the npm mount predicate into `npmroute` and have `internal/authz`'s `Mine` delegate to it.
## :link: Dependencies
Blocked by:
- [step 4](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/846) — slug-anchored authorization wiring seam
- [step 11](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/853) — npm RouteParser
- [step 12](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/854) — npm.DenialRenderer
Blocks:
- [step 20](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/862) — fail-closed boot and stub retirement
## :open_file_folder: Files
- `internal/format/npm/context.go` (Create): the hand-rolled holder, guarding a zero `NpmRepositoryID`. The package's existing `WithResolution` and `ResolutionFromContext` stay as the handler-facing read; the holder is the mutable cell the adapter can fill from inside `Assembly.authorize`, which cannot replace the request context.
- `internal/format/npm/middleware.go` (Modify): use a seeded resolution when its slug and repository name match, instead of resolving unconditionally.
- `internal/format/npm/handler.go` (Modify): build `buildMux` from an exported route table so the registration list has one source.
- `internal/format/npm/npmroute/npmroute.go` (Modify): export a `Mounts(path)`-style mount predicate beside the grammar, taking the compile-time `LiteralPos` position assertion with it.
- `internal/authz/npm.go` (Modify): `Mine` delegates to the exported predicate instead of the hand-rolled segment check that merged with the step 11 MR.
- `cmd/artifact-registry/wire_npm.go` (Modify): the adapter that fills the holder and returns the projection.
- `cmd/artifact-registry/wire_root_dispatcher.go` (Modify): in `mountSlugAnchoredFormats`, install the holder outside the authorization wrap and wrap `npmDispatcher` with `authz.NewNpmMiddleware(...)` before it is handed to `buildRootDispatcher`, behind the gate step 4 threaded.
- `docs/testing/e2e/npm.md` (Modify): move the Access control rows off `blocked`.
## :white_check_mark: Acceptance
The parity test fails if a registered route has no grammar route or a grammar route no registration, in either direction, comparing *(route, method)* pairs with every `GET` registration expanded to cover `HEAD`. A scoped packument request and an escaped tarball request are authorized, not denied 404. A path the npm dispatcher does not serve still reaches npm's own 404 rather than being claimed by the parser. An authorized request resolves once, and a request with no installed holder still resolves and serves. `internal/authz` carries no hand-rolled copy of the npm mount check: `Mine` delegates to the predicate `npmroute` exports, behavior-preserving — the existing `Mine` table and the `Mine ⊇ ParseRoute` superset pin pass with no assertion edited.
## :test_tube: Tests
`cmd/artifact-registry/wire_npm_authz_test.go` (new) for the wrap, the parity assertion in both directions, and the single-resolve assertion; `internal/format/npm/context_test.go` (new) for the holder; `internal/format/npm/handler_test.go` (Modify) to pin that the mux is built from the exported table; `internal/format/npm/middleware_test.go` (Modify) for the seeded-hit, mismatched-route, and unseeded-fallback paths; `internal/format/npm/npmroute/npmroute_test.go` (Modify) for the exported mount predicate's accept/reject table.
### :mag: Three counts to reconcile before the parity test compares anything
The spec's npm table has **12 rows** over **13 routes** — its last row carries both `/-/v1/search` and `/-/all` — while the dispatcher makes **14 registrations**, because the tarball path registers `GET` and `HEAD` separately and the packument, dist-tag map, search, and `/-/all` routes serve `HEAD` implicitly through their `GET` registrations. The parity test therefore compares *(route, method)* pairs after expanding every `GET` registration to cover `HEAD`, not raw row or registration counts.
### :leftwards_arrow_with_hook: Carried over from step 11
npm's `Mine` must delegate to a mount predicate exported by `npmroute` rather than re-implement the post-slug segment check in `internal/authz`, per the interim-mitigation paragraph of https://gitlab.com/gitlab-org/ops/artifact-registry/-/issues/392. The constraint was recorded on [work item 853](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/853#note_3745604695) and missed in the step 11 MR, so the hand-rolled check stands on `main` until this step lands; see [the carry-over note](https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/855#note_3760941692) on this item. Maven's `Mine` predates the rule and its move rides 392.
### :warning: Accepted code smell
This adds the fifth hand-rolled context holder. Same rationale as step 9; consolidation is deferred to the plan's Follow-ups.
### :handshake: Shares lines with, but does not depend on, step 9
Both add a holder install and a wrap line to `mountSlugAnchoredFormats`. Whichever merges second expects a trivial conflict.
## :books: References
- Plan: [`docs/plans/2026-08-21-s09-authorization-enforcement.md`](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-08-21-s09-authorization-enforcement.md#step-13-npm-wiring-and-route-table-parity)
- Spec: [S09 (authorization)](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/specs/S09-authorization.md)
- Parent work item: https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/652
- MR title must end with the literal marker `(S09 Enforcement plan: 13/20)`
Related to https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/652
task
GitLab AI Context
Project: gitlab-org/ops/artifact-registry
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/README.md — project overview and setup
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/AGENTS.md — AI agent instructions
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CLAUDE.md — Claude Code instructions
Repository: https://gitlab.com/gitlab-org/ops/artifact-registry
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD