docs(specs): add S15 npm remote spec and fix S13 cache-freshness model
📦 What
Adds docs/specs/S15-npm-remote.md and links it from the spec index with status
An npm remote repository is a faithful caching proxy of a single external npm registry (for example registry.npmjs.org): it relays what the upstream serves, applies exactly one transform (rewriting dist.tarball URLs back to this repository), and caches per client demand. The client surface is read-only: publish, dist-tag mutation, and unpublish return 405.
Scoped to the remote kind only, per the remote/virtual split in !900 (merged). It extracts the remote parts of the pre-split combined draft !862 (merged); the npm virtual slice remains S31.
🔧 Foundation corrections (S13)
This MR makes two S13 corrections plus one S13 interface addition, all confirmed with the S13 DRI.
Cache-freshness model. S13 mislabeled cache_validity_hours as a metadata/mutable-pointer threshold and declared npm tarballs never-revalidated. Realigned to the reference implementation's content-file-versus-metadata-file split:
cache_validity_hours→ content files (npm tarballs, Maven artifacts) and container tags;0= never revalidate, the opt-out for an immutable upstream such as npmjs.org / Maven Central.metadata_cache_validity_hours→ metadata files (maven-metadata.xml; npm packuments and dist-tags); validated> 0, so metadata always refreshes and newly published versions surface.- Only digest-keyed entities (container blobs and manifests) remain never-revalidated. No ADR change (ADR-008 blob immutability is preserved — only the
(version, filename) -> blobcache row is revalidated and repointed).
Upstream content trust. S13 previously delegated "format-specific content validation (manifest schema, Maven checksums, npm tarball integrity)" to the remote slices. Reframed: S13 relays upstream bytes faithfully and performs no format-specific content validation. The one carve-out is digest-addressed content (OCI blobs and manifests), verified against its requested digest as an intrinsic property of content-addressable storage (ADR-008, owned by S16) — that is storing-by-digest, not format validation.
Fetch interface (transform seam + per-request cap). S13's Fetch gains an optional streaming body transform (applied inside the tee, format-agnostic) and a per-request max_body_bytes override of the per-type body-size cap. Single-flight follower behavior is generalized: a raw fetch tees followers from the leader's in-flight body, a transformed fetch serves followers from the leader's committed document. S15's dist.tarball rewrite is now a consumer of the seam, not S15-owned machinery, so the transform, tee, commit-on-complete, and single-flight all stay in S13.
🎯 Design — caching proxy
- Metadata (packument, dist-tags): stream + transform. As the upstream body streams, rewrite every
dist.tarballURL and cache the upstream's own document per variant (kind=0full,kind=2abbreviated,kind=1dist-tags), lazily — a variant is fetched only when a client requests it. No parse into per-version rows, no reconstruction, no deriving one variant from another. The served document is a faithful relay plus one URL rewrite. - Tarballs: S13's teed Fetch. Stream to the client while the cache-fill commits the content-addressed blob on complete receipt. No server-side checksum gate — the client enforces tarball integrity against the
dist.shasum/dist.integritythe packument relays unchanged. - Tag installs (
pkg@tag) resolve client-side from the packument'sdist-tagsfield; the registry stores no tags and never resolves a tag during install. - Freshness: revalidate with
If-None-Match;304bumps the timestamp, a changed ETag is treated as a cold fill (new blob, repoint the row, dereference the old — reclaimed by GC).cache_validity_hours = 0pins npmjs.org;metadata_cache_validity_hours > 0keeps metadata always-revalidating. - Config:
max_remote_packument_size(64 MB) added to S11's existingNpmConfigat proto field 12, kept as a cached-document-size / streaming DoS guard (sized above vite's ~37 MB full packument). Passed per request as S13Fetch'smax_body_bytesto supersede the 16 MB metadata cap. Reuses S13'svirtual_repositoriesand S11'spublic_registry_url. No S27 dependency. - Removed relative to the pre-split draft: server-side tarball integrity verification (and its
502 upstream_content_invalid), packument reconstruction / per-version parsing, the skipped-version and dangling-tag machinery, and their metrics — see the schema reasoning below.
🧱 Schema delta and reasoning
The remote's tables were originally modeled on the hosted npm schema (S11). That was the category error: hosted is a producer that assembles a packument from many per-version fragments; a remote is a cache of a producer that receives the already-assembled document. Modeling the cache like the producer forced it to replicate assembly it never performs. The delta realigns the schema to a proxy:
- Drop
npm_remote_versions.package_json. Hosted stores per-versionpackage.jsonfragments because it assembles the packument from them (they are the source of truth, read by the packument rebuild job). A remote inherits the finished document from the upstream and never assembles, so nothing reads the column once reconstruction is removed; keeping it would also re-import the per-version filter/validate/skip adjudication a proxy shouldn't do. - Drop the
npm_remote_tagstable. dist-tags live inside the packument's owndist-tagsfield (relayed unchanged), so tag listing andpkg@taginstalls resolve from the cached document — client-side, or by proxying the dist-tags endpoint. The table mirrored data the document already carries and was the source of the dangling-latestcoherence problem. The reference implementation stores no npm tags. - Keep
npm_remote_versionsas a slim anchor (drop only the column above). Retained for thepackages → versions → fileshierarchy (parity with hosted and Maven remote, whose versions fan out into multiple files) and for version-level lifecycle/retention. Populated lazily on tarball pull, so it holds only versions actually cached — no whole-history bloat. npm_remote_metadata_filesbecomes the sole metadata store: an opaque transformed-document blob per(package, kind), populated lazily per variant. Dropped the "all three kinds rebuilt in one transaction / cannot drift" coupling — each variant is now cached independently on its own freshness clock.npm_remote_filesunchanged — still keyed by and FK'd to the slimnpm_remote_versionsanchor.
Net effect: the remote's stored shape tracks actual client demand (one document per variant touched, one blob per file pulled), the property that distinguishes a cache from a mirror.
🔗 Dependencies
- S11 npm hosted (
✅ Implemented) — format definitions, validation,NpmConfig(extended with the new field). - S13 virtual and remote foundation (
🚧 Approved spec; implementation in progress) — Lookup/Probe/Fetch, cache freshness, health monitoring, credential management, the upstream HTTP client, and single-flight; tarballs use S13's teedFetch, metadata a streamingdist.tarballtransform over the same client. The concrete Go interface and stub are agreed with the S13 DRI before the first S15 implementation MR, per the development-model conciliation process. - S06 storage (
✅ Implemented), S17 management API (✅ Approved), S08/S09 auth (stub).
📄 Notes
- Status: Approved in the spec file — merge is the approval signal, per the spec-MR guardrail.
- Reference finding. The GitLab Workhorse experiment's streaming, constant-memory rewrite of the packument's
tarballURL prefix is now the primary metadata mechanism (not a deferred optimization): S15 streams and rewrites rather than buffering, parsing, and reconstructing. - AppSec review for the npm remote/virtual format is tracked in #244.
- Deferred follow-ups: packument-first tarball revalidation (skip the tarball re-download when the strongest declared hash is unchanged); credential encryption at rest (https://gitlab.com/gitlab-org/ops/artifact-registry/-/work_items/68, GA-blocking); non-standard upstream tarball hosting (#266).
🔖 Tracking
Both under the remote-repositories epic gitlab-org&22810.