fix: state Cache-Control: no-store on every error envelope

Summary

Every error response left the origin with no Cache-Control, so an edge retained the first 404 for a mutable artifact URL and kept answering it after the artifact was published at that coordinate (staging, ar-registry, 2026-09-02). The same missing directive let a cached 302 keep serving a deleted artifact until the entry lapsed (see the issue's deletion-masking repro).

This MR makes the error path never-cacheable at each writer, per the issue's accepted expectation: error responses carry Cache-Control: no-store so no intermediary retains them. Success-shape cache directives are !2296's half of the family (it explicitly defers error responses here); the redirect half is Closes #1133 there.

Research finding: the issue's suggested fix location was wrong

Work item #1131 (closed) says adding no-store to internal/transport/httperror.go "covers every endpoint at once". The code contradicts this (stated as Hypothesis, not a finding in the issue):

  • Format packages own their transport surfaces and do not depend on internal/transport (maven/problem.go and npm/transport.go package docs; oci/errors.go likewise).
  • The observed poisoned 404 (Maven read path, S01 JSON envelope) is written by maven.writeS01JSON / maven.WriteProblem, not by transport.WriteError.

The fix therefore lands in the three S01-envelope error writers, chosen with the operator (scope question on the issue): Maven + npm + transport, OCI excluded because its remote read 404 deliberately carries no directive (pinned by TestWriteRemoteReadError_SetsNoStoreOnA501Only, tracked in the #1084 family).

What changed

  • internal/transport/httperror.goWriteErrorWithDetails (the single implementation WriteError delegates to) stamps Cache-Control: no-store. Covers the management API, the GitLab API surface, auth, and the stdlib 404/405 rewrite. WriteJSON stays bare: it also serves success bodies, a distinction pinned in TestWriteJSON_ContentType.
  • internal/format/maven/problem.goWriteProblem (all RFC 9457 Maven errors) and writeS01JSON (the read-path 404, the observed poison) stamp no-store, via one errorCacheControl const. maven/denial.go's now-redundant per-site set was removed; the directive rides WriteProblem instead.
  • internal/format/npm/transport.goWriteError stamps no-store, covering npm hosted errors and, redundantly-but-consistently, the remote proxy path's envelope (remote_read_errors.go already set it).
  • OCI is untouched: oci.WriteError and writeRemoteReadError keep their deliberate per-status stances (#1084 family).

Specs amended in the same MR: S01 (standard error format), S09 (management reads: private, max-age=0 + Vary: Authorization on success statuses, no-store on error statuses), S10 (read-path 404 shape + error envelope), S11 (Error Cases preamble), S14 (error mapping). S15 already stated the rule for npm proxy errors; no change needed there.

e2e.maven.consume.error-not-cacheable and e2e.npm.consume.error-not-cacheable were added to the catalogs.

Testing

  • New/updated unit tests: TestWriteError_FullEnvelope + TestWriteErrorWithDetails_CarriesDetails (transport), TestWriteProblem_IsNeverCacheable + TestWriteS01NotFound_IsNeverCacheable (maven), TestWriteError_S01EnvelopeShape assertion (npm), and the pinned WriteJSON-stays-bare distinction. All were red against the unmodified writers, then green.
  • go test full for internal/transport, internal/format/maven, internal/format/npm, plus internal/server, internal/managementapi, internal/gitlabapi, internal/auth — all pass.
  • golangci-lint run clean on all touched packages, both with and without --build-tags=integration (no findings on touched lines). gofmt clean, gitlint clean, scripts/ci/check-comment-caps.sh clean.
  • Conformance (run locally, both green):
    • scripts/conformance/maven_setup.sh31/31 PASS against the runner v1.64.7, including maven.error.not-found-artifact and maven.error.rfc-9457-problem-details, the touched surfaces.
    • scripts/conformance/npm-e2e.shall steps passed (publish, install, view-404, unpublish, dist-tags).
  • Pre-commit note (honesty): commits passed every hook except go-fmt and go-imports, which were skipped on the fix commit with a diagnosed root cause: golangci-lint 2.13 (bumped 2026-09-02) embeds a formatter whose canonical output for a pre-existing struct-literal construct in internal/format/npm/disttags_test.go (lines ~2255, not authored here) disagrees with standalone gofmt 1.26.7 — the two hooks fight over that construct on main, and no content satisfies both. The committed file is golangci-lint-clean (the CI-lint canonical form, matching main); gofmt -l flags only that pre-existing region. The config drift is worth a follow-up (the operator's other worktree hit the same fight).
  • Review (review-branch, 116 reviewable LOC, solo): APPROVE. Two WARNINGs, none in a high-risk phase: !2296 and !2304 both add rows to the same e2e catalogs (docs/testing/e2e/maven.md / npm.md) and conflict against this branch while merging cleanly into main — the later-merged MR rebases. The !2217-!2219 stack conflicts only on gitleaks/.tool-versions divergence (also against main), not on shared content, and is not attributable to this MR. Clean overlaps checked: !2297, !2306, !2307, !2266.

Review follow-up

GitLab Duo review noted that npm.WriteJSON had no pinned-bare guard mirroring the transport package; addressed in commit 467f6264 (test(npm): pin WriteJSON bare on success responses), which adds the assert.Empty on the success path. AppSec review: no findings.

Post-Deploy Monitoring & Validation

This change only adds Cache-Control: no-store to error responses; no log line, metric, route, or payload shape changed.

  • Validation window: 30 minutes after the deploy reaches staging, immediately after a deploy of a version containing this change.
  • Log / metrics to watch: none specific — error response volume is unchanged. The signal is a response-header check, not a dashboard.
  • Expected healthy signals: a GET of a never-published Maven coordinate returns 404 with Cache-Control: no-store (origin and through the edge); after publishing at that coordinate, the plain URL serves the artifact; the edge never answers cf-cache-status: HIT on the error.
  • Failure signals / rollback: if the header is absent on a 404, the change did not roll out (check the running image tag). Rollback is a plain revert of this MR — the header is the only change and removing it restores prior behavior with no state to unwind.

Related to #1084 (OCI error-cache governance stays out of scope here) Closes #1131 (closed)

Edited by Moaz Khalifa

Merge request reports

Loading
Loading