# 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:` 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 ` 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 [--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 /deb/{repo} ./`) or *structured* (`dists/{distribution}/` suites with components, sources line `deb /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 --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=[&component=]`; 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 [--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 `) 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.