Ctrl K
ComunidadGuías…

COMPAT.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# NORA Registry Protocol Compatibility

This document describes which parts of each registry protocol are implemented in NORA.

**Legend:** Full = complete implementation, Partial = basic support with limitations, Stub = placeholder, — = not implemented

## Support matrix

Which formats NORA proxies and hosts, the upstream each proxies by default, and what to know about each. The sections below describe the protocol coverage per format.

| Format | Pull (proxy/cache) | Push/Publish | Default Upstream | Notes |
|--------|:---:|:---:|---|---|
| Docker Registry v2 | ✅ | ✅ | `registry-1.docker.io` | hosted + proxy; cache on when `docker.upstreams` non-empty (Docker Hub by default) |
| Maven | ✅ | ✅ | `repo1.maven.org/maven2` | hosted + proxy |
| npm | ✅ | ✅ | `registry.npmjs.org` | hosted + proxy |
| Cargo | ✅ | ✅ | `crates.io` (sparse index) | hosted + proxy (sparse index) |
| PyPI | ✅ | ✅ | `pypi.org/simple/` | hosted + proxy |
| Go Modules | ✅ | — | `proxy.golang.org` | proxy only (modules immutable, push not in protocol) |
| Raw files | ❌ | ✅ | — (no upstream) | hosted only; conditional `PUT`/`GET` (ETag, `If-Match`, `If-None-Match`) on every backend; upload verification via `Repr-Digest` (RFC 9530) |
| RubyGems | ✅ | ❌ | `rubygems.org` | proxy only — `gem push` not implemented |
| Terraform | ✅ | — | `registry.terraform.io` | proxy only; client configuration notes in COMPAT.md |
| Ansible Galaxy | ✅ | ❌ | `galaxy.ansible.com` | proxy only — `ansible-galaxy collection publish` not implemented |
| NuGet | ✅ | ❌ | `api.nuget.org` | proxy only — `dotnet nuget push` not implemented |
| Pub (Dart/Flutter) | ✅ | ❌ | `pub.dev` | proxy only — `dart pub publish` not implemented |
| CPAN | ✅ | — | `www.cpan.org` | proxy only |
| Conan (C/C++) | ✅ | ❌ | `center2.conan.io` | proxy only (Conan 2 clients) |
| RPM (yum/dnf) | ⚠️ | ✅ | — (none by default) | hosted; pull-through via `[rpm.proxies]` / `NORA_RPM_PROXIES` (off by default); auto-generates `repodata/` |
| Debian/APT | ⚠️ | ✅ | — (none by default) | hosted; pull-through via `[deb.proxies]` / `NORA_DEB_PROXIES` (off by default); flat & structured layouts; auto-generates `Packages`/`Release`/`InRelease` |
| Hugging Face Hub | ✅ | — | `huggingface.co` | opt-in (`NORA_HF_ENABLED=true`, not in `all`); public models; files cached by digest, checked on the way in; never Xet; datasets not served — see COMPAT.md |

> **Helm charts** work via the Docker/OCI endpoint — `helm push`/`pull` with `--plain-http` or behind TLS reverse proxy.

> **Pull/Push legend:** ✅ supported · ⚠️ pull-through available but off by default (no default upstream) · ❌ not implemented · — not applicable (protocol has no push). Per-format details and cache strategy in [COMPAT.md](COMPAT.md).

## Docker (OCI Distribution Spec 1.1)

| Endpoint | Method | Status | Notes |
|----------|--------|--------|-------|
| `/v2/` | GET | Full | API version check |
| `/v2/_catalog` | GET | Full | List all repositories |
| `/v2/{name}/tags/list` | GET | Full | List image tags |
| `/v2/{name}/manifests/{ref}` | GET | Full | By tag or digest |
| `/v2/{name}/manifests/{ref}` | HEAD | Full | Check manifest exists |
| `/v2/{name}/manifests/{ref}` | PUT | Full | Push manifest |
| `/v2/{name}/manifests/{ref}` | DELETE | Full | Delete manifest |
| `/v2/{name}/blobs/{digest}` | GET | Full | Download layer/config |
| `/v2/{name}/blobs/{digest}` | HEAD | Full | Check blob exists |
| `/v2/{name}/blobs/{digest}` | DELETE | Full | Delete blob |
| `/v2/{name}/blobs/uploads/` | POST | Full | Start chunked upload |
| `/v2/{name}/blobs/uploads/{uuid}` | PATCH | Full | Upload chunk |
| `/v2/{name}/blobs/uploads/{uuid}` | PUT | Full | Complete upload |
| Namespaced `{ns}/{name}` | * | Full | Two-level paths |
| Deep paths `a/b/c/name` | * | — | Max 2-level (`org/image`) |
| Token auth (Bearer) | — | Full | WWW-Authenticate challenge |
| Cross-repo blob mount | POST | — | Not implemented |
| Referrers API | GET | — | OCI 1.1 referrers |

### Proxy cache and tag freshness

- Digest references (`sha256:…`) are content-addressed: cached forever, never
  revalidated.
- Tag references on **proxied** images are revalidated against the upstream on
  every pull by default (`docker.metadata_ttl = -1`). A positive
  `metadata_ttl` (seconds) is the staleness bound: within the window the
  cached tag is served without an upstream round trip; after it, the next pull
  revalidates. `0` or negative always revalidates.
- If every configured upstream fails and `docker.serve_stale = true`
  (default), the last cached manifest is served with `x-nora-stale: true`.
- Locally **pushed** (hosted) images are authoritative: a pushed tag is served
  as-is and never revalidated against an upstream. Pushing to a name that
  would otherwise proxy pins that tag to the pushed copy — to keep a tag
  tracking the upstream, do not push to it.

### Known Limitations
- Max 2-level image path: `org/image:tag` works, `org/sub/path/image:tag` returns 404
- Large monolithic blob PUT (>~500MB) may fail even with high body limit
- No cross-repository blob mounting
- Registry-mirror caching depends on Docker's image store / distribution client, not the storage driver. Docker applies the wrong credentials to mirror endpoints — it sends the upstream registry's credentials to the mirror (and on the containerd path clobbers per-mirror `hosts.toml` credentials), so an authenticated mirror pull fails and Docker falls back to the upstream, skipping the cache. Both the legacy and containerd code paths are affected ([moby/moby#30880](https://github.com/moby/moby/issues/30880), [#42022](https://github.com/moby/moby/issues/42022); upstream fix in progress in [#52532](https://github.com/moby/moby/pull/52532)). An unauthenticated mirror avoids it — set `[auth] docker_anon_pull = true` to serve pulls without a login while still requiring auth on push (this is the Docker-specific switch; the general `anonymous_read` does not open `/v2/`). For pull-through caching of non-Docker-Hub registries, the containerd image store additionally tags mirror requests with the `?ns=` upstream namespace ([containerd hosts.md](https://github.com/containerd/containerd/blob/main/docs/hosts.md)). (#578)

## npm

| Feature | Status | Notes |
|---------|--------|-------|
| Package metadata (GET) | Full | JSON with all versions |
| Scoped packages `@scope/name` | Full | URL-encoded path |
| Tarball download | Full | SHA256 verified |
| Tarball on another host (redirect) | Full | A tarball is fetched only for a version the upstream's packument lists (else 404, no tarball request). Redirects are followed to any http(s) host, at most 10 for the whole chain and within one `npm.proxy_timeout`; the upstream's credentials go only to its own origin, never past a hop that left it. An 11th redirect answers 502. Works with registries that serve tarballs from a storage host (GitHub Packages). |
| Scoped upstream (`npm.proxies` with a scope) | Full | Metadata is read from the upstream's own origin only: a packument redirect to another origin answers 502. |
| Tarball URL rewriting | Full | Points to NORA, not upstream |
| Publish (`npm publish`) | Full | Immutable versions |
| Unpublish | — | Immutable; use quarantine/blocklist to disable a version |
| Dist-tags (`latest`, `next`) | Partial | Read from metadata, no explicit management |
| Search (`/-/v1/search`) | — | Not implemented |
| Audit (`bulk` npm7 / `audits/quick` npm6) | Full | Proxy repos: forwarded to upstream verbatim; internal-namespace names stripped/refused; anonymous-read eligible. Proxied packages only (no local advisory DB). (#597) |
| Upstream proxy | Full | Configurable TTL |

## Maven

| Feature | Status | Notes |
|---------|--------|-------|
| Artifact download (GET) | Full | JAR, POM, checksums |
| Artifact upload (PUT) | Full | Any file type |
| GroupId path layout | Full | Dots → slashes |
| SHA1/MD5 checksums | Full | Stored alongside artifacts |
| `maven-metadata.xml` | Partial | Stored as-is, no auto-generation |
| SNAPSHOT versions | — | No SNAPSHOT resolution |
| Multi-proxy fallback | Full | Tries proxies in order |
| Content-Type by extension | Full | .jar, .pom, .xml, .sha1, .md5 |

### Known Limitations
- `maven-metadata.xml` not auto-generated on publish (must be uploaded explicitly)
- No SNAPSHOT version management (`-SNAPSHOT` → latest timestamp)

## Cargo (Sparse Index, RFC 2789)

| Feature | Status | Notes |
|---------|--------|-------|
| `config.json` | Full | `dl` and `api` fields |
| Sparse index lookup | Full | Prefix rules (1/2/3/ab/cd) |
| Crate download | Full | `.crate` files by version |
| `cargo publish` | Full | Length-prefixed JSON + .crate |
| Dependency metadata | Full | `req`, `package` transforms |
| SHA256 verification | Full | On publish |
| Cache-Control headers | Full | `immutable` for downloads, `max-age=300` for index |
| Yank/unyank | — | Not implemented |
| Owner management | — | Not implemented |
| Categories/keywords | Partial | Stored but not searchable |

## PyPI (PEP 503/691)

| Feature | Status | Notes |
|---------|--------|-------|
| Simple index (HTML) | Full | PEP 503 |
| Simple index (JSON) | Full | PEP 691, via Accept header |
| Package versions page | Full | HTML + JSON |
| File download | Full | Wheel, sdist, egg |
| `twine upload` | Full | Multipart form-data |
| SHA256 hashes | Full | In metadata links |
| Case normalization | Full | `My-Package` → `my-package` |
| Upstream proxy | Full | Configurable TTL |
| JSON API metadata | Full | `application/vnd.pypi.simple.v1+json` |
| Yanking | — | Not implemented |
| Upload signatures (PGP) | — | Not implemented |

## Go Module Proxy (GOPROXY)

| Feature | Status | Notes |
|---------|--------|-------|
| `/@v/list` | Full | List known versions |
| `/@v/{version}.info` | Full | Version metadata JSON |
| `/@v/{version}.mod` | Full | go.mod file |
| `/@v/{version}.zip` | Full | Module zip archive |
| `/@latest` | Full | Latest version info |
| Module path escaping | Full | `!x` → `X` per spec |
| Immutability | Full | .info, .mod, .zip immutable after first write |
| Size limit for .zip | Full | Configurable |
| `$GONOSUMDB` / `$GONOSUMCHECK` | — | Not relevant (client-side) |
| Upstream proxy | — | Direct storage only |

## Raw File Storage

| Feature | Status | Notes |
|---------|--------|-------|
| Upload (PUT) | Full | Any file type; body streams to disk (O(frame) memory), so upload size is bounded by `raw.max_file_size` alone — `server.body_limit_mb` does not apply |
| Download (GET) | Full | Content-Type by extension |
| Delete (DELETE) | Full | |
| Exists check (HEAD) | Full | Returns size + Content-Type |
| Max file size | Full | Configurable `raw.max_file_size` (default 100MB), enforced incrementally mid-stream |
| Conditional overwrite (`If-Match`) | Full | ETag-based, returns 200 on success |
| Create-only (`If-None-Match: *`) | Full | Returns 412 if resource exists |
| Directory listing | — | Not implemented |
| Immutability | Full | Default; re-upload returns 409 unless conditional headers used |

## RubyGems

Caching proxy for rubygems.org. Immutable gem/gemspec caching with TTL-based index refresh.

| Feature | Status | Notes |
|---------|--------|-------|
| Compact index (`/info/{name}`) | Full | TTL-cached |
| Gem download (`/gems/{name}-{ver}.gem`) | Full | Immutable cache |
| Gemspec (`/quick/Marshal.4.8/...`) | Full | Immutable cache |
| Full index (`specs.4.8.gz`) | Full | TTL-cached |
| Latest index (`latest_specs.4.8.gz`) | Full | TTL-cached |
| Gem push | — | Proxy-only (read) |

Client: `bundle config mirror.https://rubygems.org http://nora:4000/gems/`

## Terraform

Caching proxy for registry.terraform.io. Provider binaries are immutably cached; metadata uses TTL.
NORA serves both Terraform protocols against the same upstream: the **Registry Protocol**
(origin-registry, service-discovery based) and the **Network Mirror Protocol** (what
`network_mirror` speaks).

| Feature | Status | Notes |
|---------|--------|-------|
| Service discovery (`.well-known/terraform.json`) | Full | Registry protocol; points to NORA |
| Provider versions list | Full | TTL-cached |
| Provider download metadata | Full | `download_url` rewritten to NORA |
| Provider binary download | Full | Immutable cache |
| Module versions list | Full | TTL-cached |
| Module download | Full | `X-Terraform-Get` header pass-through |
| **Network mirror — `index.json`** | Full | Mirror protocol; list versions (#801) |
| **Network mirror — `{version}.json`** | Full | Mirror protocol; archives via NORA + `zh:` hash (#801) |
| Provider publish | — | Proxy-only (read) |

Client (network mirror — Terraform requires an `https:` URL, trailing slash):
`provider_installation { network_mirror { url = "https://nora.example.com/terraform/" } }`

**Limitations (accepted):**
- **Single upstream** — the `{hostname}` in a mirror request is validated but not routed;
  only the configured `terraform.proxy` upstream's providers resolve.
- **Mirror-mode integrity is weaker than registry mode** — in `network_mirror` mode
  Terraform does not run the origin-registry GPG check, and NORA does not itself verify
  `SHA256SUMS.sig`. Archives carry `zh:<sha256>` from upstream metadata (fail-closed: a
  platform without a resolvable shasum is omitted). Upgrade path: verify `SHA256SUMS.sig`
  against a pinned HashiCorp key at ingest.

## Ansible Galaxy (v3 API)

Caching proxy for galaxy.ansible.com. Collection tarballs are immutably cached.

| Feature | Status | Notes |
|---------|--------|-------|
| API discovery | Full | `/ansible/` and `/ansible/api/` |
| Collection list | Full | Short v3 and Pulp-style paths |
| Collection detail | Full | URL rewriting |
| Collection versions | Full | Paginated |
| Version detail | Full | Curation checks |
| Tarball download | Full | Immutable cache, both `/download/` and `/artifacts/` paths |
| Tarball curation | Full | Blocklist/allowlist, integrity verification |
| Collection publish | — | Proxy-only (read) |

Namespace and collection name validation follows Galaxy spec (`[a-z0-9_]+`).

Client: `ansible-galaxy collection install ns.name -s http://nora:4000/ansible/`

## NuGet (v3 API)

Caching proxy for api.nuget.org. Service index URLs are rewritten to point through NORA.

| Feature | Status | Notes |
|---------|--------|-------|
| Service index (`/v3/index.json`) | Full | `@id` URLs rewritten to NORA |
| Registration index | Full | TTL-cached |
| Version list (flat container) | Full | TTL-cached |
| `.nupkg` download | Full | Immutable cache |
| `.nuspec` download | Full | Immutable cache |
| Package push | — | Proxy-only (read) |
| Search | — | Not implemented |

Client: `dotnet nuget add source http://nora:4000/nuget/v3/index.json -n nora`

## Pub (Dart/Flutter)

Caching proxy for pub.dev. Package archives are immutably cached with SHA256 verification.

| Feature | Status | Notes |
|---------|--------|-------|
| Package search (`/api/packages?q=`) | Full | Response URL rewriting |
| Package metadata (`/api/packages/{name}`) | Full | `archive_url` rewritten to NORA |
| Version metadata | Full | Cached |
| Security advisories | Full | Cached |
| Archive download (`.tar.gz`) | Full | Immutable cache, SHA256 verified |
| Package publish | — | Proxy-only (read) |

Client: `export PUB_HOSTED_URL=http://nora:4000/pub && dart pub get`

## Conan (C/C++)

Caching proxy for ConanCenter (center2.conan.io). Recipe and package files are immutably cached (scoped to revision hashes). Metadata uses TTL-based caching.

| Feature | Status | Notes |
|---------|--------|-------|
| Ping (`/v2/ping`) | Full | Returns `X-Conan-Server-Capabilities: revisions` |
| Recipe search | Full | Proxied to upstream |
| Recipe latest revision | Full | TTL-cached |
| Recipe revision list | Full | TTL-cached |
| Recipe file list | Full | Immutable cache (revision-scoped) |
| Recipe file download | Full | Immutable cache |
| Package latest revision | Full | TTL-cached |
| Package revision list | Full | TTL-cached |
| Package file list | Full | Immutable cache (revision-scoped) |
| Package file download | Full | Immutable cache |
| Recipe/package upload | — | Proxy-only (read) |
| Authentication | — | Anonymous read only |

Client: `conan remote add nora http://nora:4000/conan`

## RPM (yum/dnf)

Hosted repositories with server-generated repodata (createrepo-style). Each
`/rpm/{repo}/` path is an independent repository; publishing or deleting a
package regenerates `repodata/` (repomd.xml + sha256-named
primary/filelists/other.xml.gz). Package headers are parsed server-side — no
`createrepo_c` needed on the client.

Pull-through proxy repositories: map a repo name to one upstream yum repo
(`[rpm.proxies] fedora = "https://…/Everything/x86_64/os"`). A proxied repo is
read-only (writes → 409); upstream metadata (repodata/, including the
upstream's signatures) is served verbatim within `rpm.metadata_ttl` seconds
(default 300), packages are cached forever, and a stale cache is served with
`x-nora-stale: true` when the upstream is down. `nora mirror rpm --repo
<name>` pre-fetches every package for fully offline (air-gapped) clients.

| Feature | Status | Notes |
|---------|--------|-------|
| `repodata/repomd.xml` | Full | Regenerated on publish/delete, `Cache-Control: no-cache` |
| `primary.xml.gz` | Full | name/evr/arch, provides/requires/conflicts/obsoletes with flags, primary file subset, header-range |
| `filelists.xml.gz` | Full | All files with dir/ghost types |
| `other.xml.gz` | Full | Changelogs (last 10 per package, configurable) |
| Package publish (`PUT {repo}/{name}.rpm`) | Full | Header parsed and validated; invalid RPMs rejected |
| Package delete (`DELETE {repo}/{name}.rpm`) | Full | Repodata regenerated |
| Package download | Full | Byte-identical, sha256 pkgid in repodata |
| GPG-signed repodata (`repomd.xml.asc`) | Full | Signed on every regeneration; verify with `repo_gpgcheck=1`. Object-store backends (S3/GCS) require `signing.key_path` — without it signing is disabled with a startup warning. A provisioned key must be Ed25519 (since 1.4.2); any other algorithm is a fatal startup error |
| Public key (`repodata/repomd.xml.key`) | Full | Armored, for `gpgkey=`; key auto-generated at first boot |
| Package signatures (`gpgcheck=1`) | — | Packages are stored as uploaded; NORA signs metadata, not packages |
| Reconcile (`POST {repo}/-/reindex`) | Full | Heals out-of-band storage changes: drops orphan metadata, adopts added packages, rebuilds + re-signs repodata |
| Upstream proxy | Full | Per-repo pull-through via `[rpm.proxies]`; digest quarantine via `[curation.rpm]` |
| Offline mirror | Full | `nora mirror rpm --repo <name> [--arch x86_64,noarch]` warms the full package set |
| sqlite metadata (`*_db`) | — | XML metadata only (all modern dnf/yum versions) |
| Delta RPMs (`prestodelta`) | — | Not generated |
| Module metadata (`modules.yaml`) | — | Not generated |

Publish: `curl -u user:pass -T pkg.rpm http://nora:4000/rpm/myrepo/pkg.rpm`

Client `.repo` file:

```ini
[nora-myrepo]
name=NORA myrepo
baseurl=http://nora:4000/rpm/myrepo
enabled=1
gpgcheck=0
repo_gpgcheck=1
gpgkey=http://nora:4000/rpm/myrepo/repodata/repomd.xml.key
```

`gpgcheck=0` stays: NORA signs the repository metadata, not the packages
themselves. With signing disabled (`signing.enabled = false`), also set
`repo_gpgcheck=0`.

## Debian (APT)

Hosted repositories with server-generated indexes, in either apt layout
(chosen per package at publish): *flat* (indexes at the repo root, sources
line `deb <url>/deb/{repo} ./`) or *structured* (`dists/{distribution}/`
suites with components, sources line `deb <url>/deb/{repo} jammy main`).
Each `/deb/{repo}/` path is an independent repository; publishing or
deleting a package regenerates every affected index. Package control
paragraphs are parsed server-side from the .deb
(ar → control.tar.{,gz,xz,zst}) — no `dpkg-scanpackages` needed.

Pull-through proxy repositories: map a repo name to one upstream apt repo
(`[deb.proxies] debian = "https://deb.debian.org/debian"`). A proxied repo is
read-only (writes → 409); upstream indexes (dists/, including the upstream's
InRelease/Release.gpg) are served verbatim within `deb.metadata_ttl` seconds
(default 300), packages are cached forever, and a stale cache is served with
`x-nora-stale: true` when the upstream is down. `nora mirror deb --repo
<name> --dist <dist>` pre-fetches every package for fully offline
(air-gapped) clients.

| Feature | Status | Notes |
|---------|--------|-------|
| `Packages` / `Packages.gz` | Full | Verbatim control paragraphs + Filename/Size/MD5sum/SHA1/SHA256 |
| `Release` (flat repo) | Full | MD5Sum + SHA256 sections, `Cache-Control: no-cache` |
| `dists/` layout (suites/components) | Full | `PUT ...?distribution=<dist>[&component=<comp>]`; per-dist Release + `{component}/binary-{arch}/Packages{,.gz}`; arch-`all` packages folded into every concrete arch index; upload path is free-form (`Filename` is repo-root-relative, `pool/` not required) |
| Package publish (`PUT {repo}/{name}.deb`) | Full | Control parsed and validated; invalid debs rejected; decompression bombs bounded |
| Package delete (`DELETE {repo}/{name}.deb`) | Full | Indexes regenerated; emptied distributions are removed |
| Package download | Full | Byte-identical |
| `InRelease` (clearsigned) / `Release.gpg` (detached) | Full | Signed on every regeneration (per distribution in the structured layout); verify via `signed-by` |
| Public key (`pubkey.gpg`) | Full | Armored, for the `signed-by` keyring; key auto-generated at first boot |
| Reconcile (`POST {repo}/-/reindex`) | Full | Heals out-of-band storage changes: drops orphan metadata, adopts added packages (into the flat layout — placement is upload-time intent), rebuilds + re-signs indexes |
| by-hash | — | Not generated; index files are served `Cache-Control: no-cache` |
| Translations / Contents indexes | — | Not generated (proxied repos pass them through) |
| Upstream proxy | Full | Per-repo pull-through via `[deb.proxies]`; digest quarantine via `[curation.deb]` |
| Offline mirror | Full | `nora mirror deb --repo <name> [--dist <dist>] [--component main] [--arch amd64]` warms the full package set |

Publish (flat): `curl -u user:pass -T pkg.deb http://nora:4000/deb/myrepo/pkg.deb`

Publish (structured):
`curl -u user:pass -T pkg.deb "http://nora:4000/deb/myrepo/pool/pkg.deb?distribution=jammy&component=main"`

Client setup:

```
curl -fsSL http://nora:4000/deb/myrepo/pubkey.gpg -o /etc/apt/keyrings/nora.asc
# flat
echo "deb [signed-by=/etc/apt/keyrings/nora.asc] http://nora:4000/deb/myrepo ./" \
  > /etc/apt/sources.list.d/nora.list
# structured
echo "deb [signed-by=/etc/apt/keyrings/nora.asc] http://nora:4000/deb/myrepo jammy main" \
  > /etc/apt/sources.list.d/nora.list
```

With signing disabled (`signing.enabled = false`), use `[trusted=yes]`
instead of `[signed-by=...]`.

## CPAN

Caching proxy for www.cpan.org. Distribution tarballs are immutably cached;
the package index uses TTL-based caching.

| Feature | Status | Notes |
|---------|--------|-------|
| Package index (`02packages.details.txt.gz`) | Full | TTL-cached |
| Module list (`03modlist.data.gz`) | Full | TTL-cached |
| Author directory (`01mailrc.txt.gz`) | Full | TTL-cached |
| Distribution download (`authors/id/*`) | Full | Immutable cache |
| Distribution publish | — | Proxy-only (read) |

The web UI groups cached releases by parsing archive filenames with a local
adaptation of `CPAN::DistnameInfo`. It supports modern `Dist-1.23` names and
legacy underscore, dot, and directly-attached version forms. `.meta` and
`.readme` sidecars are not fetched or parsed for grouping. Consequently, the
distribution page lists versions already cached by NORA; unparseable archive
names are omitted from the grouped author view. The exact rules and examples
are documented in [the CPAN proxy design](docs/design/2026-09-18-cpan-proxy-design.md#distribution-grouping-in-the-ui).

Client:
```bash
cpanm --from http://nora:4000/cpan Module::Name
```

## Hugging Face Hub

Pull-through proxy for public models on huggingface.co, **opt-in**: `NORA_HF_ENABLED=true`
(`[hf] enabled = true`); `NORA_REGISTRIES_ENABLE=all` leaves it off. Upstream: `[hf] proxy` /
`NORA_HF_PROXY` (default `https://huggingface.co`; empty disables the upstream), timeout
`[hf] proxy_timeout` / `NORA_HF_PROXY_TIMEOUT` (default 30 s).

| Endpoint | Method | Status | Notes |
|----------|--------|--------|-------|
| `/hf/api/models/{repo}` | GET | Full | Model info; `xetHash` removed at any depth |
| `/hf/api/models/{repo}/revision/{rev}` | GET | Full | Revision as the client encoded it (`refs%2Fpr%2F1` stays one segment) |
| `/hf/api/models/{repo}/tree/{rev}[/{path}]` | GET | Full | Paginated listing; `Link rel="next"` moved under `/hf` |
| Other `GET /hf/api/models/…` | GET | Full | Passed through as JSON, without Xet; a body that is not JSON is a 502 |
| `/hf/api/models/{repo}/xet-read-token/…`, `xet-write-token/…` | GET | Refused (404) | NORA never serves Xet |
| `/hf/{repo}/resolve/{rev}/{file}` | HEAD | Full | Answered from the Hub's resolve answer (commit, etag, size); the CDN is not asked |
| `/hf/{repo}/resolve/{rev}/{file}` | GET | Full | Cached by digest; `Range` supported |
| `/hf/api/datasets/…`, `/hf/datasets/…` | * | — (501) | Models only in this release |
| Uploads (`POST`, `PUT`, `DELETE`, `PATCH`) | * | — (405) | Pull-through only |

Client: `HF_ENDPOINT=https://nora.example.com/hf hf download org/model` (also `hf_hub_download`,
`snapshot_download`). Measured with huggingface_hub 0.36.0, 1.0.0, 1.33.0, 2.0.0, 2.1.1 and 2.2.0
against a fixture Hub in the live Hub's shapes, and with 2.1.1 against huggingface.co: a regular
file, an LFS file of a Xet-backed repo, a legacy repo id (`gpt2`), revisions `refs/pr/N` and a
commit, every request through NORA.

### Proxy cache and freshness

- NORA follows the Hub's redirects itself — the relative 307 to its resolve cache, the 302 to its
  CDN for an LFS file — so no `Location` reaches the client and every byte comes through NORA.
  A redirect is followed only to the upstream's origin or, over https, to a CDN host: `[hf]
  cdn_hosts` / `NORA_HF_CDN_HOSTS` (comma-separated), each entry and the names under it, default
  `hf.co` and `huggingface.co` (the Hub's CDN, `us.aws.cdn.hf.co`, is one). An IP address
  matches as an address (`::1` and `0:0:0:0:0:0:0:1` are one), never as a suffix. http to a CDN
  host is followed only along a walk that has been http from an http upstream on (a test or a
  LAN mirror), never down from an https hop. An entry admits every port of its host, so
  `localhost` or an internal name does not belong in a production list. A redirect anywhere else
  is a 502 before that host is asked, for a HEAD as for a GET, and does not count toward opening
  the circuit breaker.
- A revision or path that decodes to a walk out of the repository — `main%2F..%2Fother`, `%5C..`
  — is refused before the upstream is asked; `refs%2Fpr%2F1` still goes as one segment.
- The Hub's resolve answer is asked on every download: it names the commit, which curation reads,
  and the file's digest. `main` therefore never goes stale, and the bytes come from the cache when
  NORA already holds that digest.
- Files are kept per repository by the digest the Hub names them by (`X-Linked-Etag`: the sha256 of
  an LFS file, the git blob sha1 of a regular one), under `hf/models/{repo}/blobs/{digest}`. A weight
  file that does not change across commits is downloaded once.
- On the way in, a file is checked against that digest with its last chunk held back until it
  matches: a corrupt file ends one chunk short and is not stored. The client sees the transfer end
  before the file is complete (for a file of one chunk, before any header): a streamed answer cannot
  change its status once it has begun, so there is no `502`. A file read back from the cache
  is checked against its pin the same way, and a copy that fails is dropped, so the next download
  fetches it again; a range read from the cache is not checked (a part cannot be rehashed), as for
  every format.
- A range request on a file not yet cached, a HEAD, and a file whose etag names no digest are passed
  through and not stored. A client that leaves early leaves nothing behind.
- Cached files count toward `[gc] proxy_cache_max_bytes` with rpm and deb, oldest first. Retention
  rules that name `hf` prune them (`older_than_days`, and `keep_last` counted in files); a `*` rule
  does not apply to the opt-in format.

### Curation

A download is named by its repository id and versioned by its commit: a rule on the repository
refuses before the upstream is asked, a rule on a commit holds for `main` when `main` resolves to it,
also for a file already cached. When curation can refuse by name (curation on, or an internal
namespace set), an alias — a legacy id such as `gpt2`, another case, a renamed repository — is
checked under its canonical id too, learned from the upstream (`HEAD /api/models/{id}`); if NORA
cannot learn it, enforcing curation refuses the download (502), while audit logs it under the
requested id and goes on. A commit no hop of the Hub's answer names is not "any version": an
allowlist of commits lets nothing through without one. `curation.min_release_age` with hf enabled is
refused at start: a Hub commit has no publish date NORA can read.

**Limitations (this release):**
- **Datasets and Spaces** are not served (501).
- **Public repositories only** — a client's token is never sent upstream, so a gated or private
  repository answers with the Hub's own refusal (its status, `X-Error-Code` and message).
- **One anonymous client at the Hub** — every request reaches the Hub from NORA's address without a
  token, so the Hub's anonymous rate limits are shared by every client behind NORA (cached files
  still cost a resolve request each). A `429` is handed on with `Retry-After`, `RateLimit` and
  `RateLimit-Policy`, by which huggingface_hub waits for the window to reset, and says it is a rate
  limit when the Hub's answer names no reason.
- **The Hub must be reachable** for a download: the resolve answer is asked every time, and there is
  no stale serving yet.
- **`HfFileSystem`** is not measured; its `paths-info` call is a `POST` and answers 405.
- **An alias and its canonical id are cached separately** (`gpt2` and `openai-community/gpt2`).
- **The web UI** counts cached files and their size on the dashboard and in `/api/ui/stats`, per repository in `/api/ui/hf/list`, but has no page that lists them yet.
- **`nora backup` includes the cache** — every stored weight file, which the next download would fetch
  again; there is no option to leave `hf/` out yet, so on a large cache keep it within
  `[gc] proxy_cache_max_bytes` or a retention rule before a backup.
- **S3**: set an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket. A cache fill cut off
  during its upload (a restart) leaves multipart parts the object store keeps until they are aborted;
  NORA cannot abort them itself after a restart.

## Helm OCI

Helm charts are stored as OCI artifacts via the Docker registry endpoints. `helm push` and `helm pull` work through the standard `/v2/` API.

| Feature | Status | Notes |
|---------|--------|-------|
| `helm push` (OCI) | Full | Via Docker PUT manifest/blob |
| `helm pull` (OCI) | Full | Via Docker GET manifest/blob |
| Helm repo index (`index.yaml`) | — | Not implemented (OCI only) |

## Cross-Cutting Features

| Feature | Status | Notes |
|---------|--------|-------|
| Authentication (Bearer/Basic) | Full | Per-request token validation; see OIDC note below |
| Anonymous read | Full | `NORA_AUTH_ANONYMOUS_READ=true` |
| Rate limiting (429 + Retry-After) | Full | `tower_governor`, per-IP, documented in OpenAPI |
| 405 Method Not Allowed + Allow | Full | RFC 9110 §15.5.6, multi-method routes return Allow header |
| Prometheus metrics | Full | `/metrics` endpoint |
| Health check | Full | `/health` |
| Swagger/OpenAPI | Full | `/api-docs` |
| S3 backend | Full | AWS S3, Ceph RGW. Basic storage works on any S3-compatible; multi-replica write-serialization has a caveat — see note below. |
| GCS backend | Full | Native Google Cloud Storage (`storage.mode = "gcs"`): Workload Identity / service-account JSON / ambient credentials; endpoint override for emulators and Private Google Access. Same single-writer caveat as S3 for rpm/deb publishing (in-process publish lock). Hash-pinning (at-rest integrity verification) works on every backend: the pin is the `sha256` object metadata on S3/GCS. |
| Local filesystem backend | Full | Default, content-addressable |
| Activity log | Full | Recent push/pull in dashboard |
| Backup/restore | Full | CLI commands |
| Mirror CLI | Full | `nora mirror` for npm/pip/cargo/maven/docker/rpm/deb |

### OIDC authentication transport

OIDC JWT tokens are validated only on the **Bearer** authentication path.
Docker, twine, and Maven clients transmit credentials via HTTP Basic, and the
Basic-auth handler recognises htpasswd passwords and opaque API tokens (`nra_…`)
but does **not** attempt OIDC JWT validation on the password field. To use OIDC
workload identity from CI, send the JWT as a `Bearer` token (e.g.
`Authorization: Bearer <jwt>`) rather than as a `docker login` password. For
`docker login` specifically, use an opaque API token created via the UI or
`/api/tokens`. (#853)

### Storage backend notes

- **Multi-replica write-serialization needs conditional-write/CAS.** NORA's write lock
  (`publish_lock`) is safe under a **single writer** — one replica, or an RWO volume
  single-mounted so only one pod writes. Serializing concurrent writes across multiple
  replicas requires an object store with atomic conditional-write / compare-and-swap:
  AWS S3 and Ceph RGW provide it; **Garage** (no consensus layer) and **SeaweedFS**
  (immature) do not — on those, run single-writer. Not every "S3-compatible" backend is
  equivalent here.