Skip to content

BatleHub — Roadmap ​

Planned features and improvements, grouped by theme. Within each group the order reflects rough implementation priority.

For discussion or to propose a feature, open an issue on the project repository.


New registry types ​

Current adapters: npm, Cargo, GitHub, Forgejo/Gitea, GitLab, OpenVSX, VS Code Marketplace, Go modules, Maven, RubyGems, Terraform, Composer, PyPI, Conda, NuGet, Deb (APT), RPM (YUM/DNF), Pacman, JetBrains (IDE archives), JetBrains Marketplace (plugins), Generic (path-addressed file mirror), Node distributions (nodedist), SDKMAN (sdkman), Rust toolchains (rustup), Alpine / apk (apk), Ansible Galaxy (galaxy), Nix binary cache (nix).

  • [x] PyPI — Python package index; Simple API proxy with URL rewriting; wheel / sdist downloads; private publishing via twine in local/hybrid mode
  • [x] Maven / Gradle — Maven Central-compatible metadata XML + JAR / POM downloads; private publishing via mvn deploy in local/hybrid mode
  • [x] RubyGems — gem downloads and version listing (proxy + local/hybrid with publish/yank/unyank)
  • [x] NuGet — .NET package protocol; NuGet v3 service index + flat container proxy; .nupkg and .nuspec downloads; private publishing via dotnet nuget push in local/hybrid mode
  • [x] Deb / RPM — Debian APT (type = "deb") and Red Hat YUM/DNF (type = "rpm") repository proxying and private hosting in local/hybrid mode: .deb/.rpm publish, Packages/Release and repodata/ regeneration, Ed25519 OpenPGP-signed metadata (InRelease/Release.gpg, repomd.xml.asc). Signing is hand-rolled (Ed25519 only) to avoid the banned rsa crate
  • [x] JetBrains IDE archives — type = "jetbrains": proxy-only path-based cache for JetBrains IDE installer archives (default upstream download.jetbrains.com); reuses the generic PathProxyRegistryClient. No private hosting. IDE archives are large (~1-1.7 GB), so limits.max_artifact_size_bytes must be raised
  • [x] JetBrains Marketplace — type = "jetbrains-marketplace": full marketplace emulation for the plugin ecosystem (default upstream plugins.jetbrains.com) in proxy/local/hybrid mode. IDE-facing surface (search, compatible updates, meta.json blobs, plugin/download, pluginManager) complete enough for idea.plugins.host full replacement, plus updatePlugins.xml for the additive idea.plugin.hosts flow; marketplace-compatible multipart publish (POST /api/updates/upload) so plugin-repository-rest-client/Gradle publishPlugin work against local/hybrid registries. Metadata, artifacts, and forwarded query blobs are all cached with stale fallback, so previously-seen plugins keep resolving if plugins.jetbrains.com is unreachable
  • [x] VS Code extension gallery — type = "openvsx" / "vscode-marketplace": the client-facing gallery protocol (POST …/vscode/gallery/extensionquery, assets, resourceUrlTemplate, item) so an editor can be pointed at BatleHub with extensionsGallery in product.json and search, install and update through it, plus the OpenVSX REST API (/api/{namespace}/{extension}, /api/-/search, …/file/{name}) for ovsx. Both protocols render from one entry list, so blocked versions are hidden from both. Assets — manifest, README, changelog, licence, icon — are extracted from the cached VSIX, so one artifact answers them all. The editor sends no credentials to its gallery, so a gallery registry needs anonymous read or an authenticating ingress
  • [x] Terraform registry — provider and module proxy protocol; private module + provider publishing in local/hybrid mode
  • [x] GitLab releases and packages — type = "gitlab": paginated release list/tag, link assets, source archives + raw files via the /-/ URL scheme, nested groups, PRIVATE-TOKEN/Bearer auth; package registry passthrough (/api/v4/…, ideal for generic packages). Ecosystem package registries (npm/Maven/PyPI/…) are proxied via the matching typed adapter pointed at the GitLab package endpoint
  • [x] Forgejo releases and packages — type = "forgejo": paginated Gitea/Forgejo /api/v1 release list/tag, assets, source archives, raw files (reuses the GitHub URL scheme); package registry passthrough (/api/packages/…). Ecosystem registries via the matching typed adapter
  • [x] Git-forge registries: refs, releases, raw (RFC 0019, docs/rfc/0019-git-forge-registries-refs-releases-raw.md) — github, gitlab and forgejo take a tag, a branch or a commit where they took a release tag only. The ref is resolved once and the coordinate becomes its commit SHA, so a cache entry is a commit and never a name that moves; X-BatleHub-Ref-Kind, -Commit, -Requested and -Previous-Commit say what the name resolved to and what it answered last time. [registries.refs] sets the branch TTL and what a mutable ref costs (MUTABLE_REF warned by default, a moved tag and a replaced asset denied); [registries.raw] serves single files, off by default, bounded by size, allowlisted by repository glob and path, and refusing a shell script (RAW_SCRIPT) on a quarantined registry; [registries.api_reads] turns on three typed read-only families (tags, commits, branches) with no wildcard passthrough. The commit date, the publisher and the forge's own attestations ride the version into RFC 0018's verdict. Landed 2026-09-03 → 2026-09-05, proven client-side by tests/heavy/mise.sh
  • [x] Composer — PHP Composer registry (Packagist v2 protocol — packages.json, p2 metadata, dist downloads); private package publishing via ZIP upload in local/hybrid mode
  • [x] Anaconda / Conda — Python data science package registry; repodata.json proxy and channel merging; .tar.bz2 and .conda package parsing; private channel publishing in local/hybrid mode
  • [x] Arch Linux / Pacman — type = "pacman": proxy upstream Arch mirrors and private hosting in local/hybrid mode: .pkg.tar.{zst,xz,gz} publish (metadata read from .PKGINFO), per-arch <repo>.db/<repo>.files database regeneration, Ed25519 OpenPGP-signed database (<repo>.db.sig) and packages (.sig + embedded %PGPSIG%) so SigLevel = Required works. Signing reuses the hand-rolled Ed25519 signer (the rsa crate is banned)
  • [x] Generic file mirror — type = "generic": proxy-only path-addressed cache for any HTTP file tree that has no package protocol at all (toolchain tarballs, vendor CDNs, release buckets). Reuses PathProxyRegistryClient like jetbrains, but with a mandatory explicit upstreams entry and a mandatory path_allow glob allowlist so a registry pointed at a shared host (e.g. storage.googleapis.com) cannot become an open relay for every other path on it. Covers the toolchain sources that no typed adapter reaches — rustup (static.rust-lang.org), the Go toolchain (dl.google.com/go, distinct from the goproxy module adapter), and single-binary vendor CDNs (get.helm.sh, binaries.sonarsource.com). Archives can be large, so limits.max_artifact_size_bytes may need raising. Node (nodejs.org/dist) was on this list and is the reason nodedist below exists: generic mirrors it correctly and can enforce nothing on it, because a path-addressed registry has no version to block. generic stays the right answer for a tree you want cached without policy

Not yet started, in rough priority order:

  • [x] Node distributions (nvm, fnm, n, mise) — type = "nodedist": the nodejs.org/dist tree as a typed adapter rather than a generic mirror, so a Node release can be blocked rather than merely cached. index.tab and index.json are filtered listings; the tarballs and SHASUMS256.txt are artifacts under {name}/{version}/{file}. The point is the identity, not the bytes: generic already caches this tree, and because it addresses everything as one synthetic package (repo/_) there is no version to block, nothing in explore and no per-version statistics — a gap the working cache hides. index.tab is also the enforcement chokepoint, because nvm_remote_version resolves every install through it, including a fully specified one. SHASUMS256.txt is passed through byte-exact: nvm verifies against it and a sibling .asc/.sig signs it. Shipped with RFC 0010 phases 1–4 on 2026-09-03, verified by tests/heavy/nvm.sh against nvm 0.40.3; the console setup snippet and the registry page landed with phase 9 on 2026-09-04 (RFC docs/rfc/0010-toolchain-managers.md §13.1, §13.2)
  • [x] JVM toolchains (SDKMAN) — type = "sdkman": the JDK, Gradle, Maven-the-distribution and the Kotlin compiler, none of which any BatleHub instance has ever seen. Proxies the candidates API and the download broker as one registry (broker_url beside cargo's index_url), filters versions/all, candidates/default and the rendered sdk list table, and refuses a blocked version at candidates/validate so SDKMAN prints its own "is not a valid … version" rather than failing mid-download. The broker answers 302 to third-party CDNs, so the redirect chain is followed server-side through the SSRF guard — otherwise the 200 MB JDK still leaves the site and the proxy has mediated a policy decision and none of the bytes. Hook scripts are relayed byte-exact; they are bash the client sources and runs Shipped with RFC 0010 phases 5–9 on 2026-09-04, verified by tests/heavy/sdkman.sh against sdkman-cli 5.23.0 (the refusal, the broker redirect, the relayed hook and the cache all observed on the wire); warm_platforms and the .nvmrc/.sdkmanrc inputs of registry suggest landed with it, as did the console entries and registry pages for both toolchain kinds (RFC docs/rfc/0010-toolchain-managers.md §13.2)
  • [ ] Generic file mirror, local/hybrid mode (RFC 0025, docs/rfc/0025-generic-local-mode.md, Draft) — publish arbitrary files under {name}/{version}/{filename}, the equivalent of GitLab's "generic packages": internal build artifacts, installers, blobs from CI. Inherits quotas, ownership, Ed25519 artifact signing, SBOM, yank and dedup from the shared local-registry machinery
  • [x] Alpine apk (RFC 0026, docs/rfc/0026-alpine-apk.md, Implemented) — type = "apk": the missing member of the OS family beside deb, rpm and pacman, and the base of most container images. Proxy mode is a path tree per {branch}/{repo}/{arch} with APKINDEX.tar.gz relayed byte-exact, not filtered — the index is RSA-signed over its bytes and every shipping apk refuses an unverifiable one — so the block is enforced at the package, whose file name carries a real coordinate: apk is the one path kind with a block list, an age gate and a row in explore. local/hybrid mode hosts and signs its own APKINDEX with RSA-2048/SHA-256 through aws-lc-rs, without the banned rsa crate, and rotates keys additively through apk_signing.previous_keys. An air-gapped instance composes that index over the packages it holds and signs it — the only OS kind that can, because the others' indexes are signed by keys the estate does not have. Proven live against both apk generations (2.14.10 and 3.0.8) in tests/heavy/apk.sh, which found five defects on its first run that 43 tests had passed; see RFC 0026 §13.
  • [ ] rustup dist (RFC 0024, docs/rfc/0024-rustup-dist.md, Draft) — type = "rustup": the Rust toolchain (static.rust-lang.org/dist) as a typed adapter, for the same reason nodedist left generic: generic caches the tree and can block nothing on it. The channel manifests (channel-rust-{stable,beta,nightly}.toml, dated ones under {date}/) are the filtered listing and the enforcement chokepoint — a blocked nightly or a denied component disappears from the manifest, so rustup toolchain install fails the way it fails upstream — and the per-target component tarballs with their .sha256 siblings are artifacts. RUSTUP_DIST_SERVER is the one client switch. The manifest's .asc (an RSA PGP signature from upstream) is relayed byte-exact and never re-signed; a filtered manifest therefore no longer matches it, and rustup's verification is off by default and warns rather than fails — a rewritten manifest is documented as such, as the air-gap listings of RFC 0008-bis are
  • [ ] Nix binary cache (RFC 0028, docs/rfc/0028-nix-binary-cache.md, In review; every phase landed 2026-09-17, and the heavy suite reaches the publish phases without yet clearing them) — type = "nix": the substituter protocol — nix-cache-info, one {hash}.narinfo per store path (the per-package listing), nar/{hash}.nar.{xz,zst} as artifacts, realisations/ for content-addressed derivations. narinfo signatures are Ed25519 (Sig: cache.nixos.org-1:…), the only registry protocol whose signing matches this codebase's constraint natively, so local/hybrid mode signs what it hosts with the shared Ed25519 signer and a client lists the registry's key in trusted-public-keys; upstream signatures are relayed, never re-signed. Blocking is by the version parsed from the store name (the hash is the artifact, not a thing an admin names); the Sig: does not cover URL:, so the served narinfo's NAR URL is rewritten to carry that coordinate for free. The air gap needs no new bundle entry kind after all: a narinfo's unrecoverable fields — its closure, its deriver, its signatures — travel in BundleEntry::facts, the field RFC 0008-bis §13.7 added for exactly that, and the disconnected side composes the document and relays the publisher's signature untouched. Clients: nix, nix-serve-shaped tooling, cachix-style substituters entries
  • [ ] Homebrew (RFC 0027, docs/rfc/0027-homebrew.md, Draft) — type = "homebrew": two upstreams behind one brew install. The JSON API (formulae.brew.sh/api, switch HOMEBREW_API_DOMAIN) serves formula.jws.json and cask.jws.json, the listing every install resolves through — and it is a JWS signed by Homebrew (PS512, an RSA key shipped in brew itself, verification fatal and un-skippable: "Potential MITM attempt detected"), so a filtered listing cannot exist: the document is relayed byte-exact, and no version is ever hidden. The bottles live on GitHub Packages (ghcr.io/v2/homebrew/core, switch HOMEBREW_BOTTLE_DOMAIN) as two read-only OCI endpoints — {formula}/manifests/{version} and {formula}/blobs/sha256:{digest}, anonymous Bearer QQ== — and the coordinate lives in the manifest path, so the download gate is the only enforcement point — at the blob, not the manifest: brew rescues a failed manifest fetch and carries on, so the refusal has to land on the bottle bytes, whose coordinate the proxy derives from an index over the cached listing, and brew fails on its own download error. Which is where the real problem sits: every mirror variable falls back to the default domain when the mirror answers anything but a bottle, so a refusal from the proxy is a bypass unless the client also sets HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK — the registry page has to say that in its first paragraph, and the heavy suite has to prove a 403 stays a 403. Two limits the RFC states: homebrew/core publishes one version per formula (the API lists no history), so "block a version" is "block the formula until it bumps", closer to DenyLatestRule than to a block list; and casks and source tarballs are out of reach — brew 6.0.22 rewrites only ghcr.io URLs through HOMEBREW_ARTIFACT_DOMAIN (the manpage still describes a general prefix the code no longer applies), so no brew variable routes a vendor download through the proxy and there is no open relay to allowlist. Bottle attestations (HOMEBREW_VERIFY_ATTESTATIONS, Sigstore via gh) are verified against GitHub, not the mirror, and are untouched. HOMEBREW_NO_INSTALL_FROM_API (git checkouts of the taps) stays out of scope. The bottle store being OCI-shaped does not reopen the Not planned note below: it is two anonymous read endpoints for one namespace, not a registry
  • [ ] Helm charts (RFC 0029, docs/rfc/0029-helm-charts.md, Draft) — type = "helm": a real chart repository (index.yaml + .tgz) needs URL rewriting in the served index — for the cache, and for credentials: Helm sends a repository's basic auth only to the same scheme and host, so an unrewritten absolute URL also loses authentication — which is why it warrants its own adapter rather than a generic instance; local mode would regenerate index.yaml from the DB the way conda regenerates repodata.json. Note that the helm binary (get.helm.sh) is already covered by generic — this entry is about charts. OCI-based charts stay out of scope (see below) — and since Helm 3.8 defaulted to OCI, the classic repository is the shrinking half; the entry is here for the charts that still ship one (in-house chart museums, jetstack, ingress-nginx, prometheus-community; Bitnami's 27 MB index.yaml now points almost entirely at oci:// URLs, which the RFC drops from the served index by default)
  • [ ] JSR (RFC 0030, docs/rfc/0030-jsr.md, Draft) — type = "jsr": the Deno/modern-Node registry at jsr.io — /@{scope}/{name}/meta.json (the per-package listing), /@{scope}/{name}/{version}_meta.json, and the module files under /@{scope}/{name}/{version}/… as artifacts — plus its npm-compatibility endpoint (npm.jsr.io, the npm tarball protocol), which the npm adapter can serve as a second upstream rather than a second kind. Client switches: JSR_URL for Deno, .npmrc @jsr:registry= for npm/pnpm/yarn
  • [x] Ansible Galaxy (RFC 0031, docs/rfc/0031-ansible-galaxy.md, Implemented) — type = "galaxy": the collections API v3 (/api/v3/collections/{ns}/{name}/, /versions/, /versions/{v}/ with its download_url), /api/ discovery, and the artifact tarballs; roles (/api/v1/roles) are proxied read-only behind roles = proxy | index | off, with the limit the RFC found: ansible-galaxy role install builds a github.com/{user}/{repo}/archive/{version}.tar.gz URL itself and only prefers the download_url the v1 versions document carries, so rewriting that field is what routes role bytes through the proxy — and a role with no published versions installs from its default branch with the proxy never seeing it. ansible-galaxy collection install resolves every dependency through the versions list, so it is the enforcement chokepoint; download_url is rewritten to this instance or the tarball bypasses the proxy (the Terraform X-Terraform-Get lesson). Every listing is served as a single page with a null next: the client joins a pagination link in a way that drops a path prefix (and for roles strips it deliberately), so no link this instance can emit survives — the adapter walks upstream's 100-version pages itself. Blocking the newest version also bumps the collection document's updated_at, because that field is how ansible-galaxy decides whether to drop its day-old copy of the listing. local/hybrid mode accepts ansible-galaxy collection publish (POST /api/v3/artifacts/collections/) synchronously and answers the import-task poll it does afterwards with a task that is already finished. Client switch: server_list in ansible.cfg
  • [ ] Chocolatey and PowerShell Gallery — NuGet v2 (RFC 0032, docs/rfc/0032-nuget-v2.md, Draft) — the nuget adapter speaks v3 only; the classic PowerShellGet and choco 1.x speak the v2 OData protocol (/api/v2/Packages(), FindPackagesById(), $filter/$orderby/$skip, /package/{id}/{version} downloads). The RFC corrected the premise on both sides: choco 2.x builds its sources with NuGet's own GetCoreV3 factory and does read v3 feeds (it only loses the ListResource optimisation), while the decisive gap is the upstream — community.chocolatey.org has no v3 endpoint at all (/api/v3/index.json → 404), so no value of upstreams proxies it today, and PowerShell Gallery is registered as a v2 repository by its own current client. The RFC therefore adds a v2 axis on both sides of the kind, upstream client and server surface, rendered from one model. Adds the v2 surface to the existing nuget kind rather than a new one — same .nupkg, same PackageId, same block list — with OData rendered from the same data the v3 registration pages are; local/hybrid publishing already exists on v3 and choco push uses the same PUT /api/v2/package. chocolatey.org and powershellgallery.com become supported upstreams; PSResourceGet (v3-capable) is the regression check that v3 kept working

Not planned: Docker / OCI artifacts. Harbor solves this better than we could, unless concrete demand arises.


Cache policy ​

  • [x] Honour Cache-Control headers from upstream responses (no-cache, max-age, no-store) to decide whether and how long to cache
  • [x] Eviction policies: TTL-based expiry, "not accessed for N days", garbage-collect all versions except the latest N, storage size cap with LRU eviction
  • [x] Cache index coherence: compare what is actually in the storage backend against what the registry metadata expects, and recover from corruption or manual deletions
  • [x] Content-addressable deduplication: identical artifact bytes are stored once regardless of how many logical keys (registries, package names) reference them — ref-counted via artifact_dedup_index / artifact_dedup_refs, backwards-compatible with pre-dedup artifacts
  • [ ] Storage-backend migration — move stored artifacts from one configured backend to another (filesystem ↔ S3) when [storage] changes. Artifacts already record which backend holds them, but there is no migrate, move or rebalance operation, so changing the backend strands everything already written: it stays reachable only while the old backend remains configured. Needs a resumable walk, partial-failure tolerance and dedup ref-count awareness (RFC 0004-bis §13.3)
  • [x] Proactive cache warming: pre-fetch known versions of configured packages on startup and on demand via the admin API (POST /api/v1/admin/registries/{registry}/warm); configurable per registry with warm_packages, warm_latest_n, and warm_concurrency

Metrics & observability ​

  • [x] Prometheus metrics endpoint (/metrics): request counts, cache hit/miss rates, latency percentiles, error rates per registry
  • [x] Health check endpoint (/healthz) that verifies connectivity to the database and all configured storage backends
  • [x] Stats dashboard on the admin home screen: hits/misses, bandwidth saved, per-registry and aggregate

Artifact integrity & security ​

  • [x] Verify checksums for downloaded artifacts when the upstream provides them — per-registry [registries.integrity]: on the proxy fetch path the buffered bytes are hashed and compared against the metadata checksum (Cargo SHA-256, npm SRI/shasum, PyPI SHA-256). Supports SRI (sha512-…) and bare hex (algorithm inferred from length)
  • [x] Block serving an artifact if its integrity check fails, or optionally if the upstream provides no integrity metadata at all — a mismatch fails the download with 502 and is never cached (not bypassable); require_metadata = true additionally blocks downloads with no advertised checksum (with bypass_roles)
  • [x] Sigstore / npm provenance verification — the sigstore scanner of RFC 0018 ([scanners.sigstore], rekor_url, require_for = ["npm"]) verifies npm provenance attestations against Rekor; require_provenance = true on a registry's security profile turns a missing, invalid or unverifiable attestation into a PROVENANCE_MISSING / PROVENANCE_INVALID / PROVENANCE_UNVERIFIABLE finding and so into the verdict, and a forge's own attestations reach the same finding through RFC 0019
  • [ ] cargo verify-project-style verification for Cargo crates
  • [x] Detect and optionally require signed releases for GitHub, OpenVSX, and VS Code Marketplace — RequireSignedReleaseRule reads a best-effort is_signed signal populated by the GitHub/Forgejo and OpenVSX/VS Code Marketplace adapters; deny_missing_signature + bypass_roles control enforcement
  • [x] Allowlist of trusted publishers — trusted_publisher rule; supported for GitHub/GitLab/Forgejo (owner/group), npm (scope or publishing user), OpenVSX/VS Code Marketplace (publisher segment). Cargo crate owners not yet supported — crates.io ownership isn't in the sparse index and would need a separate API call
  • [x] Allowlist of approved versions; blocklist of specific versions with known issues — version_gate rule (allow/block with exact or semver-range matching)
  • [x] Vulnerability scanning via the OSV API to block or warn about packages with known CVEs — periodic SBOM re-scan plus a per-registry cve_gate rule (min_severity, block/warn-only, bypass_roles)
  • [x] Licence policy rule — license_gate: allow/deny SPDX expressions per registry, allow_unknown for the case where the licence is not yet known, block vs warn-only and bypass_roles like cve_gate. Distinct from RFC 0002's licence flag kind, which needs an external source to assert one; this reads what the package declares in its own manifest. Licence extraction landed with it: ArchiveSbomExtractor now returns the declared licence alongside the dependencies from the same parse, in five ecosystems (cargo, npm, maven, pypi, nuget), stored on artifact_sboms.license and surfaced on both the admin and explore package-detail pages. Two limits, both warned about in config rather than left to be discovered: the licence is read during SBOM generation, so the rule is inert unless [registries.sbom].enabled = true (license-gate.sbom-disabled); and it is inside the archive, so the first request for an uncached package cannot be gated (allow_unknown). A license_gate on one of the sixteen registry types with no parser raises license-gate.no-extractor, or license-gate.denies-everything when block + allow_unknown = false would refuse every download (RFC 0004-bis §13.1, §14.1)
  • [x] An artifact is scanned before it is served (RFC 0018, docs/rfc/0018-supply-chain-quarantine-and-verdicts.md) — a per-registry [registries.security] profile turns the proxy into a quarantine: what comes from upstream is scanned by a configurable set of scanners (osv, trivy, postmortem, guarddog, sigstore, and the external socket / mlab clients), the findings are evaluated into a persisted verdict — allowed, warned, quarantined, denied — and only the first two are served. Versions older than a configurable maturity age are served while their scan is pending, so the layer can be switched on over a live cache. The verdict carries machine-readable reason codes and reaches a reader through each registry's own protocol error, batlehub why, the Package Explorer and the notification channels, behind quarantine:read and findings:read; overriding one is an administrator action (GateExemption), and a signed inbound webhook lets a SOC push a rescan or a verdict. Scanning runs in a worker role of the same binary ([server] roles, default ["proxy", "worker"]; --roles worker splits it out over the scan_jobs queue), and the scanner toolchains live only in the worker image (Containerfile.worker). Every phase of §12 landed; the client-facing claims are proven by tests/heavy/quarantine.sh
  • [ ] Sandbox runtimes (RFC 0022, docs/rfc/0022-sandbox-runtimes.md, Draft) — where a scanner's process runs is hard-wired to bwrap, and where the host forbids unprivileged user namespaces (Ubuntu 24.04's AppArmor default, most hardened Kubernetes nodes, every rootless container without --userns) bwrap starts, prints nothing, and the worker reads the empty output as a scanner error, so the deployment is either unsandboxed or not scanning. Turn "where a scanner runs" into a port — bwrap, an OCI container, a Kubernetes pod — with what every runtime must prove
  • [x] A block is visible to every ecosystem, not only to npm (RFC 0006) — the listing half of a block (leaving the version out of what a client is told exists, so a resolver never picks it) was built for npm alone; on the other twenty kinds the client resolved latest to the blocked version and the install failed, so the block read as breakage rather than policy. Every kind that has a listing document filters it, the ones that cannot are stated, and the coverage table in the admin guide is generated from RegistryKind::listing_filter(), an exhaustive match a new kind cannot be added without answering (task docs:listing-coverage:check)
  • [x] A package that vanishes upstream is noticed, held and reported (RFC 0014, docs/rfc/0014-upstream-disappearance.md) — a periodic audit asks each proxy/hybrid upstream whether what we cached from it is still there, confirms a disappearance across several sweeps before believing it, notifies through the configured channels and exempts a confirmed disappearance from TTL and idle eviction, since eviction had no idea it was holding the last copy in the estate. on_confirmed = "audit" (default) reports and holds; on_confirmed = "block" treats an unpublish as hostile and refuses it on the wire. Landed 2026-09-04, driven by tests/heavy/upstream_audit.sh
  • [x] Signed VSIX assets for openvsx / vscode-marketplace registries (RFC 0020, docs/rfc/0020-signing-at-the-vscode-marketplace-registry.md) — a current VS Code's Extensions view greys out Install on any gallery entry without a signature asset. A registry holding an Ed25519 key ([registries.vsx_signing] seed_hex, key_id) signs every VSIX it publishes and serves the signature in Open VSX's archive shape as Microsoft.VisualStudio.Services.VsixSignature, the key as …PublicKey and at GET …/api/-/public-key/{key_id}; versions published before the key existed are signed on first request, a rotated key re-signs the same way, and an upstream's own archive can be attached to a republished version (PUT …/{ext}/{version}/vsix/signature) and is served as-is — the one a stock editor verifies. batlehub-cli vsx keygen / vsx verify. Phases 1, 2, 3 and 5 landed 2026-09-06, measured by tests/heavy/vsx_view.sh in a real Extensions view
    • [ ] Phase 4, a signature crossing the air gap — deferred, and decided in principle, until a VSIX crosses the gap at all: the bundle is mise.lock-driven and names no VSX kind (RFC 0020 §13.4, §11 decision 10)
  • [ ] YARA rule evaluation for custom malware or policy patterns
  • [ ] Antivirus scanning for binary artifacts (VSIX, Go module zips) via a configurable external REST API — RFC 0018's scanner port is where it plugs in (the socket and mlab clients are the two external scanners it already carries); no antivirus scanner is written
  • [x] Warn when an upstream registry is returning high error rates or slow responses and cached data may be stale — in-process EMA of upstream error rate / latency per registry (ProxyMetrics); a batlehub_upstream_health_degraded{registry} gauge plus a tracing::warn! on the healthy→degraded transition; also surfaced in GET /api/v1/admin/stats (upstream_degraded, upstream_error_rate, upstream_latency_ms)
  • [x] Warn when a registry does not provide integrity metadata for its artifacts — the proxy logs a warning and increments batlehub_integrity_checks_total{outcome="missing"} when an artifact is fetched with no advertised checksum (and blocks instead when require_metadata = true)

Authentication providers ​

  • [x] Static tokens — plain-text and Argon2id-hashed Bearer tokens in config.toml; batlehub hash-token CLI
  • [x] OIDC — JWT validation via OIDC discovery + JWKS; browser SSO (Authorization Code flow); role and group mapping from claims; namespaced group prefixes for multi-provider setups
  • [x] Kubernetes service accounts — TokenReview API validation; role and group mapping; in-cluster defaults
  • [x] GitHub / Forgejo Actions OIDC (type = "actions-oidc") — validate short-lived JWTs issued to workflow jobs; map claims (repository, ref, environment, actor, …) to groups and roles via configurable rules; supports static group names and dynamic group templates (e.g. "{name}/{repository}/{ref_name}" → "forgejo-action/batleforc-batlehub/main"); glob and regex pattern matching; AND / OR condition logic per rule
  • [x] Groups on a personal access token — a PAT resolves to its creator's subject and a subset of their groups. user_tokens.groups (migration 046) holds a snapshot taken at creation; snapshot_pat_groups (crates/core/src/entities/grant.rs) caps it to what the creator holds and refuses anything else with a 403 naming it, rather than dropping it silently; UserTokenAuthProvider::to_identity returns it, so RFC 0015's group: subjects match a PAT and pat_is_within_owner no longer compares against a set that can only be empty. The console picks the groups from the caller's own on TokensPage.vue and lists what each token carries — a snapshot goes stale silently, and that column is where an owner sees a team they have left; the CLI takes --groups/--all-groups and shows the snapshot in auth token create and auth token list. Omitting them means none, which is what every token minted before this carried. RFC 0011-bis phase 1, the one gap of the three that RFC 0015 absorbed by requirement and did not build (docs/rfc/0011-bis-namespace-scoped-visibility.md); 0011-bis's first open question closed with it — expiry is mandatory and capped at 90 days, which is what bounds how long a stale snapshot grants read
  • [x] Signed download URLs for the request that carries no credential (RFC 0012, docs/rfc/0012-signed-urls-for-terraform.md) — Terraform authenticates the two JSON documents of a provider install and then fetches the archive with no Authorization header (measured against a real client; it has no mechanism to send one there). The documented workaround, anonymous = ["releases:read", "source:read"], opened every read on the registry. signed_downloads = true mints a signed, expiring, single-coordinate URL inside the document that was already authenticated, and the archive route accepts that signature as evidence of the authentication that happened; it carries the identity that fetched the document and runs the same rule chain — it authenticates a request and authorises nothing. Requires [server.signed_urls].secret; setting one without the other is a startup error. All seven phases landed 2026-08-27

Rate limiting & DoS protection ​

  • [x] Per-user and per-registry rate limits on API requests and artifact downloads, with configurable thresholds and time windows (in-memory token bucket; state resets on restart)
  • [x] Configurable enforcement policies: hard block vs. soft warn when a limit is reached
  • [x] Explicit rate-limit warnings in API responses (Retry-After, X-RateLimit-* headers)
  • [x] Per-group rate limits (shared token-bucket pools per OIDC/Kubernetes group; enforcement override per group)
  • [x] IP-based blocking for abusive clients, with configurable block duration and thresholds
  • [ ] Integration with external IP reputation services to automatically block known malicious IPs

Quota management ​

  • [x] Per-user, per-group, and per-registry quotas on storage usage and number of published packages
  • [x] Enforcement policies: block publish requests that exceed the quota, or allow with an explicit warning
  • [x] Quota warnings in API responses and admin UI when a limit is being approached
  • [x] Admin API for resetting quotas for specific users, groups, or registries

Hot reloading & dynamic config ​

  • [x] Watch the config file for changes and prompt an admin for confirmation before applying — file watcher (using notify crate) loads a pending reload; admin confirms via POST /api/v1/admin/config/pending/apply or discards it
  • [x] Validate the new config before applying it (schema check + connectivity probes) to avoid breaking a running server — schema validation runs immediately; connectivity probes (HEAD to each upstream with a 5s timeout) run before the pending reload is stored
  • [x] Partial reloads: update RBAC rules or add/remove a registry without restarting the process — registries, policies, versioning, signing, and beta-channel maps are all behind Arc<RwLock<HotConfig>>; in-flight requests finish with the old data before the swap
  • [x] API endpoint for triggering a config reload (POST /api/v1/admin/config/reload) for automation when file-watching is unavailable — also GET /api/v1/admin/config/pending, POST /api/v1/admin/config/pending/apply, DELETE /api/v1/admin/config/pending
  • [x] Audit trail for all config changes (who triggered, what changed, when) — stored in config_changes table; retrievable via GET /api/v1/admin/config/changes
  • [x] Global admin banner — broadcast a message (info / warning / error) to all website visitors; automatically set during a reload and cleared on completion; backed by in-memory, Redis, or PostgreSQL depending on the cache backend; PUT/DELETE /api/v1/admin/banner + /admin/config-reload UI page
  • [x] BATLEHUB_DISABLE_HOT_RELOAD=1 — disable the file watcher and all reload endpoints (e.g. when config is a read-only Kubernetes ConfigMap)
  • [x] Config warnings — non-fatal problems (AppConfig::warnings()) logged at startup and on every reload, served from GET /api/v1/admin/config/warnings, returned inline by /config/validate and /config/from-content, and rendered on the Config Reload admin page. Each carries a stable code and the path of the offending config location. /config/from-content reports the candidate's warnings even when the submitted bytes match the last load attempt, rather than those of the config still in force
  • [x] pending_created on the reload responses — /config/from-content answers 200 with an empty diff both when it stages a pending and when the submitted content is identical to the last load attempt and there is nothing to stage. The flag distinguishes them, and the Config Reload page shows a distinct notice instead of a success that sends the admin to an Apply button answering 404 No pending reload
  • [x] Layered config files — --config is repeatable, and each further file is a layer merged over the ones before it (batlehub_config::load_layered): the merge is on the TOML documents, before deserialisation, so a later layer can complete a table an earlier one opened — tables merge key by key, arrays of tables match on name then type, everything else is replaced, later layers win. A Kubernetes Secret can sit beside a ConfigMap, a 0600 file beside a readable one, without giving up hot reload on either: every layer is watched and re-read on reload, and the first layer stays the only one the config editor reads or rewrites. BATLEHUB_CONFIG takes the same list separated by :. The single-file case keeps its old code path so its error spans survive (docs/guide/configuration.md § Layered config files)
  • [ ] Clear the upstream-detail absence cache on reload — UpstreamDetailCoordinator remembers which coordinates an upstream answered 404 for, and clear_absent exists to forget them. Its doc says "for tests, and for a config reload that changes what 'absent' would mean"; audited 2026-09-01, no reload path has ever called it. So a reload that adds an upstream, fixes its base URL or repairs its credentials leaves every coordinate that failed under the old configuration still remembered as missing, and the operator's fix appears not to work until the process restarts. One call on the reload path, and a test that a reload forgets an absence
  • [ ] Dynamic blocking rules fetched from an external trusted source (e.g. a signed Git repository); verify signatures before applying
  • [ ] Dynamic allowlists of trusted publishers or approved versions, fetched from an external source and merged into RBAC / block rules automatically

Ingress & routing ​

  • [x] Host-based (subdomain) registry routing — bind a registry to one or more hostnames whose root serves it, in addition to /proxy/{name}/…. [subdomain_routing] derives <name>.<base_domain> for every registry; registries[].hosts adds vanity hosts. One outermost middleware rewrites the URI, so none of the ~249 route definitions change; the host table is hot-reloadable like every other registry-scoped map (RFC docs/rfc/0001-subdomain-routing.md)
  • [x] Every self-referencing URL reflects the ingress the client used — one registry_public_base helper replaced 8 ad-hoc base-URL builders, ~25 format!("{base}/proxy/{registry}/…") sites and the base_url contract of 5 LocalRegistryService methods
  • [x] registries[].path_routing = false — make a registry reachable only by host, so isolated content does not also answer on the shared main host (404, not 403)
  • [x] Proxy trust ([server].trusted_proxies) — one CIDR list governing Forwarded / X-Forwarded-Host / X-Forwarded-Proto / X-Forwarded-For alike, resolved once per request into a PeerTrust verdict every downstream middleware reads. Closes the previously unconditional trust of the forwarded host and scheme; deprecates [ip_blocking].trusted_proxies, whose unparseable entries are dropped with a config warning rather than refused at startup (that key predates the validator, so rejecting them would fail the boot of a config that never changed)
  • [x] The policy is hot-reloadable and swapped before the host table it guards, so a reload that turns host routing on can never run under the startup verdict — the two are not atomic together, and this order makes the gap fail closed
  • [x] The rate limiter and the IP-block middleware bucket and ban on the same client IP — both read the shared verdict, so behind a trusted proxy neither keys on the proxy address
  • [x] Registry names are matched on the path actix routes on, not the raw URI — percent-encoding a character of the name no longer slips past the path_routing = false 404 or a registry's rate limit
  • [x] RegistryInfo.public_url on GET /api/v1/registries, used by the Setup Guide and namespace upload snippets — the .netrc block lists every host a client may authenticate against (entries are matched by hostname, so the main host alone left host-routed registries unauthenticated), and the Terraform source drops the registry segment on a host-routed registry, where it is no longer part of the path
  • [x] Helm ingress.extraHosts + documented config.server.trusted_proxies
  • [ ] Per-host TLS certificate management inside the server (currently the ingress's job)
  • [x] Instance-to-instance transfer for air-gapped estates — export a set of approved artifacts plus their metadata as a signed bundle, and import it into a disconnected instance. [air_gap] makes a server that will not dial out: a miss is a 503 that names itself and is recorded, so the next bundle's contents are produced by the estate rather than guessed. batlehub mise plan|seed|export|import turns mise.lock into the bill of materials, and the bundle is a tar of a signed manifest plus sha256-addressed blobs — so identical bytes ship once and the identity is the same one content-addressable dedup uses (RFC 0004-bis §13.2). Verification moves to the connected side and the verdict travels with the bytes. Proven end to end by tests/heavy/mise.sh §4: mise install against a second instance with egress denied (RFC docs/rfc/0008-mise-in-an-air-gapped-estate.md)
  • [x] A disconnected instance answers the listing a client resolves through (RFC 0008-bis, docs/rfc/0008-bis-listings-across-the-gap.md) — a bundle carries artifacts and the entry that finds them, never the document a package manager reads first, so npm install left-pad@1.3.0 and pip install six==1.17.0 stopped at a 503 on an instance that held the very thing they wanted. An [air_gap] instance now composes that document from what it holds — one version per held key, nothing else — and marks it X-BatleHub-Listing: synthesised with the count in X-BatleHub-Listing-Held. Every kind with a listing is rendered in its own shape, including the four whose answer lives inside the artifact and is read at import (RubyGems' compact index, conda's repodata.json, NuGet's registration pages, Composer's p2) and Terraform's download document, composed from the held archive, checksum list and signature with the publisher's own keys carried on the manifest — this instance signs nothing. A version the instance does not hold is absent from the listing rather than served, so the client fails the way it fails upstream; the miss log gained the version asked for and the versions held beside it. synthesise_listings = false restores the old refusal. All six phases landed 2026-09-05; ten clients prove the whole in tests/heavy/airgap.sh, re-run green 2026-09-07

Webhooks & notifications ​

  • [x] Subscribe to notifications for specific packages, versions, or registries (new version published, version deprecated, package removed)
  • [x] Multiple notification channels: email, Slack, Microsoft Teams, outbound webhooks
  • [x] User-configurable notification preferences and channel configuration in the UI
  • [x] Inbound webhook API so external systems (CI pipelines, security scanners) can push events into BatleHub and trigger notifications or policy updates

Private registry features ​

Applies to registries running in local or hybrid mode.

  • [ ] Seed a registry from an incumbent — import the contents of an existing Nexus, Artifactory or Verdaccio, not just the config for a new instance (batlehub-cli registry suggest already does the latter). This is the migration path for the users most likely to adopt BatleHub, and without it every adoption starts from an empty cache. Needs a per-source adapter plus an ingestion path that reuses the existing publish machinery so quotas, ownership, signing and SBOM all still apply (RFC 0004-bis §13.4)

Per-registry additions ​

  • npm — versioning policies (enforce semantic versioning, allowlist version patterns)
  • Cargo — versioning policies; verify full compatibility with the yank protocol from crates.io
  • VS Code extensions — deprecation and unlisting; upload via the UI (form for VSIX + metadata), in addition to the existing PUT API
  • [x] Maven — private artifact publishing via mvn deploy; POM-triggered three-phase publish; JAR/checksum pre-upload; dynamically generated maven-metadata.xml from DB; local and hybrid modes
  • [x] Terraform — private module publishing (tar.gz upload, X-Terraform-Get redirect); private provider publishing (version manifest + per-platform binary upload); local and hybrid modes
  • [x] PyPI — private wheel / sdist publishing via twine; Simple API served from DB; local and hybrid modes
  • [x] Conda — private channel with repodata.json generation from DB; .tar.bz2 and .conda package upload; local and hybrid modes

For all private registry types ​

  • [x] Writing grants at the package and version tiers (RFC 0017, docs/rfc/0017-writing-grants-at-the-package-and-version-tiers.md) — RFC 0015 built the two deepest tiers of the grant hierarchy and left them without an editor: the only put_grant caller was the ownership projection, which writes package rows carrying exactly releases:publish + owners:read + owners:write, so releases:read on one package for one group — RFC 0015 §4.4's own opening example — was unexpressible and the version tier, read on every authorize, could not be populated at all. Both halves shipped in one change, which the RFC requires rather than recommends: grants:read/grants:write in the vocabulary and held by role:admin at the instance tier, GrantAdminService as the single write funnel (expansion at write, the ownership-verb 409, the redundancy and spent-coordinate warnings), three admin routes, batlehub admin grants list|set|rm, and a console panel on the package detail page — and filter_by_grants in load_visible_versions, which gave filter_listing and package_visibility their first caller. Rule 3 landed with them: DocumentAudience now names the caller's version-tier grants, because the rubygems compact index filters every version of every gem and two callers differing by one version row are entitled to different documents. Inert until an operator writes the first version-tier grant, which is asserted rather than assumed. Two corrections to the RFC. Its before/after example described a version grant narrowing what another subject sees, which §4.3's "grants only widen" forbids — the filter is what a releases:list-without-releases:read caller sees, and that is now what the document says. And §6.3's "explain needs no change, it resolves through the same path" was false: resolution_path composes the config-declared tiers, and the stored ones are appended by authorize_grants after a short-circuit no diagnostic reaches, so explain and access-check both answered deny where the server answers allow — explain while naming package:…/version:… in tiers_walked. Both now take resolution_path_for_coordinate; the enforcement path keeps its short-circuit, because grants union and the two agree. A third defect was found by Bundler rather than by any test: every whole-registry document gates each name on Readable::contains, which composed the config and package tiers and stopped — so a caller whose only grant was a version-tier row was told the package does not exist, and the per-version filter was never reached for it. /info/{gem} filtered correctly throughout, which is why nothing caught it: curl asks /info directly and Bundler does not, it resolves from /versions, and an empty one sends it to the legacy specs.4.8.gz index this server answers 404. Failed closed, so no disclosure — but §4.4 rule 2 was unreachable in practice. Readable::with_version_grants admits the name; tests/heavy/authz.sh rubygems now asserts bundle install resolves to the granted 2.0.0 while 3.0.0 exists and is newer
  • [x] Artifact signing framework: publish with X-Artifact-Signature / X-Signature-Type headers; signature stored in DB and returned on download; optional signing.required enforcement. Optional download-time verification of stored ed25519 signatures against configured signing.trusted_keys (signing.verify_on_download); Ed25519 only, since the rsa crate is banned
  • [x] Ownership and team management: per-package owner table (user/group, admin/maintainer roles); initialize_owner on first publish; can_publish check on subsequent publishes; admin API to list/add/remove owners
  • [x] Versioning policies: enforce_semver, allow_prerelease, version_pattern (regex) per registry; enforced at publish time with HTTP 422
  • [x] Beta / pre-release channel: allow specific users or groups to access unpublished versions before general release
  • [x] Bulk operations: POST /api/v1/admin/registries/{registry}/bulk-yank|bulk-unyank|bulk-delete
  • [x] A published version coordinate is never reused (RFC 0016): delete is a soft delete, so 1.4.0 cannot mean two different things to two different lockfiles. The bytes go, a tombstone stays, and a re-publish onto the coordinate is refused for good — the crates.io/PyPI model rather than npm's, which matters more here because a private registry is frequently the only copy of what it holds. A package name is released when its last version goes, its owners with it; the version numbers stay spent
  • [x] Retention for locally published versions (RFC 0016): keep_versions, keep_for_days, keep_if_pulled_days as a union of vetoes — a version survives if any matches, so wrong configuration fails toward keeping. dry_run on by default, a per-version pin that outranks the policy, a floor date before which an absent download record proves nothing, and a rate limit for the first live run. Distinct from cache eviction, which discards something a re-fetch brings back
  • [x] Tombstone compaction: a deleted version's checksum, publisher and metadata age out on a window; the coordinate claim never does, and there is deliberately no setting that removes it
  • [x] Content-addressable deduplication for stored artifacts (ref-counted via artifact_dedup_index / artifact_dedup_refs)
  • [x] Integrity verification: verify checksums on re-serve, not only at publish time — integrity.verify_on_serve re-hashes stored bytes against a self-computed SHA-256 (recorded when first cached) on every serve (proxy cache hits and local reads); a mismatch fails with 502 and evicts the corrupt entry
  • [x] A forge release becomes an installable package (RFC 0021, docs/rfc/0021-forge-releases-into-registries.md) — CI builds an artifact and attaches it to a release; a github/gitlab/forgejo registry made that asset downloadable, and an editor still could not see it, because a gallery is served by a registry of its own kind. A [[release_imports]] block names a repository, its asset globs and a target registry, and the instance publishes what the release carries into it: signed at publish exactly as an uploaded version is, scanned and audited like any other. An import is a publish, so every gate on that path applies and there is no second door. POST /api/v1/admin/registries/{registry}/import runs one now (cache:warm to ask; the configured principal's own releases:publish to do it), interval_secs runs it on a schedule, a version the registry already holds is skipped, latest means the newest release that is neither a draft nor a pre-release, and the publisher is a principal declared in the config, never an admin — the config refuses one at load. The coordinate comes from the asset's file name by each ecosystem's own convention, the rules batlehub publish always used, now shared with the server. Phases 1–3 and 5 landed with v1.2.0 on 2026-09-09 (batlehub-cli command, console block with its last run)
    • [ ] Phase 4, signature passthrough — the release asset's own signature carried into the target registry rather than re-signed (RFC 0021 §12)
    • [ ] Phase 6, the heavy suite — a real release imported into a real Extensions view (RFC 0021 §12)

CLI tool - batlehub-cli ​

  • [x] a CLI for common private registry tasks (publish, yank, list), suitable for use in CI pipelines — batlehub-cli binary in cli/; global flags --profile, --server, --token, --registry, --json and env-var equivalents (BATLEHUB_*)
    • [x] Publish command that wraps the upload API, with support for multiple registry types and automatic metadata extraction from the artifact (e.g. extension, archive contents, manifest files) — batlehub-cli publish <file>; detect_meta auto-detects registry type, name, and version
    • [x] Version management commands for yanking, unyanking, or deleting specific versions — batlehub-cli version yank|unyank|delete
    • [x] Package management commands for listing versions, viewing metadata, or managing owners — batlehub-cli package list|versions and batlehub-cli owners list|add|remove
    • [x] Authentication support for static tokens and token management — batlehub-cli auth whoami and token list|create|revoke; token passed via --token / BATLEHUB_TOKEN
    • [x] List of available registries and their types, with per-registry configuration details — batlehub-cli registry list|info
    • [x] Suggest the set of registries a project needs — batlehub-cli registry suggest scans the working directory (mise.toml/mise.lock tool backends plus the usual manifests: Cargo.toml, package.json, go.mod, …), maps each detected source to a registry type, and emits the [[registries]] TOML block (or JSON) to paste into config.toml. --client-env additionally prints the environment variables that point each toolchain at BatleHub
    • [x] List packages and versions in a registry, with filtering options — batlehub-cli package list|versions
    • [x] Autocompletion support for shell integration — batlehub-cli completion bash|zsh|fish|... generates and prints the completion script; pipe to shell RC file
    • [x] Config file support for storing credentials and default options, with CLI overrides — ~/.config/batlehub/config.toml with named profiles; batlehub-cli config init|show|set
    • [x] Config file output for both CI automation and human use — config init interactive wizard; --json flag on all commands for machine-readable output
    • [x] The commands each RFC brought with it — batlehub-cli mise plan|seed|export|import (RFC 0008: mise.lock as the bill of materials, a signed content-addressed bundle, non-zero on a miss so it works as a CI gate); auth token, auth write-token-file, auth status, --kubernetes-token-path and proxy serve (RFC 0011: the credential contract file keyed by origin, and a loopback gallery proxy for an editor whose gallery URL can be repointed); authz explain and authz shadow (RFC 0015); admin grants list|set|rm (RFC 0017); why, admin flags list and admin exposure (RFC 0018 / 0002); vsx keygen and vsx verify (RFC 0020)
    • [x] An editor with no credential hook can sign in to a private extension registry (RFC 0011, docs/rfc/0011-openvsx-login.md) — a credential contract file, keyed by origin and described by a normative JSON Schema, carries a token or a path to one, re-read per request; a che-code patch in patches/che-code/ reads the same file and retries once on 401; with no credential the loopback proxy answers a search with exactly one entry, the sign-in bootstrap, instead of a blank view. Landed 2026-09-04 → 2026-09-06; tests/heavy/vsx_login.sh drives the real VS Code core through it, and the editor extension lives in the separate batlehub-vsx repository
  • [x] A TUI mode for interactive use — batlehub-cli tui launches a ratatui / crossterm terminal UI
    • [x] List of registries with search and filter capabilities — registry_list screen
    • [x] Per-registry package explorer with version details and management actions — package_list screen (live search/filter) + package_detail screen (yank / unyank keybindings)
    • [x] Interactive prompts for publishing new versions — publish_form screen with auto-detected name and version fields
    • [x] Help setup registry for a current project by scanning local files — TUI SetupWizard screen (s from registry list); detects Cargo.toml, go.mod, package.json, pyproject.toml, pom.xml, composer.json, *.gemspec, *.nuspec, *.csproj, *.tf, environment.yml; shows per-type config snippets and publish commands
    • [x] Auth workflow integration for OIDC and Kubernetes service accounts, including token caching and refresh — batlehub-cli auth login (OIDC browser flow + K8s token path); auth refresh; oidc_refresh_token / oidc_expires_at / kubernetes_token_path persisted in profile; auto-refresh on startup; TUI Login screen (L from registry list) with three-tab method selector

SBOM support ​

  • [x] Proxy existing SBOMs from upstreams that provide them (GitHub dependency graph API, npm bom.json) — enabled by fetch_upstream = true in [registries.sbom]
  • [x] Generate a minimal per-artifact SBOM (SPDX 2.3 or CycloneDX 1.4) at proxy time, from registry metadata and the downloaded archive
  • [x] Org-level SBOM export: all artifacts served in a time range as a single merged document (GET /api/v1/sbom/export?from=…&to=…&format=spdx|cyclonedx) — admin UI at /admin/sbom
  • [x] Generate SBOMs at upload time for private registries, extracting dependency manifests from the archive (go.mod, Cargo.toml, package.json, pom.xml, requirements.txt)
  • [x] Policy option: deny publishing a private package if no manifest is found in the archive (required = true in [registries.sbom])
  • [x] Per-artifact SBOM accessible from the Package Explorer version detail view (SPDX and CycloneDX download buttons per version)
  • [x] Periodically re-check cached SBOMs against vulnerability databases (see Artifact integrity) and update block / warn metadata automatically — [vulnerability_scan] background task queries OSV and records findings, surfaced per-version in the Package Explorer and admin views
  • [x] Third-party vulnerability flags (RFC 0002, docs/rfc/0002-vulnerability-flags-and-exposure.md) — a [[flag_sources]] entry signs a batch to POST /api/v1/flags/{source} with X-Hub-Signature-256, one item per coordinate at an exact version or *, and DELETE /api/v1/flags/{source}/{external_id} revokes it. The effect is capped by the source's max_effect and scoped to the registries it names. On a registry with [registries.security] the flag becomes a finding of kind SocVerdict: a hard_block denies the stored verdict at once and re-derives on rescan, a gate is judged against the registry's max_severity, and the only relief is a GateExemption; on a registry without a profile the same store is read by a rule of its own beside the block list. The inbound security.verdict webhook is the degenerate case of the same push. Landed 2026-09-04, npm through the whole lifecycle 2026-09-06 (tests/heavy/quarantine.sh step 8)
  • [x] Vulnerability exposure reporting (same RFC) — GET /api/v1/admin/exposure joins the download audit trail with pushed flags and scanner findings alike: one row per consumer, coordinate and flag, with how many pulls preceded the flag (the retroactive case), a coverage block saying what the report could not see, keyset paging and a CSV or JSON export. batlehub admin flags list and batlehub admin exposure are the terminal side; the console carries the flags and exposure panels. Read-only
  • [ ] Pushed flags in the protocol-native advisory feeds — a flag today reaches a client when it meets the gate on download; surfacing the same flag through npm audit, cargo audit and the other native advisory feeds would let the resolver read it a step earlier, and nothing more. Recorded as a follow-up, not a blocker (RFC 0002 §11 q1)

UI improvements ​

  • [x] Package explorer (/explore) — collapsible catalog with registry sidebar; search and sort across all cached and upstream packages; per-package detail page showing version history with gate/firewall status per version; [registries.rbac.explore] config block for independent search permissions
  • [x] Package explorer caching and pagination for large registries (e.g. npm) to avoid fetching the entire index on every request; cache invalidation on new versions published or cache expiry
  • [x] Package detail pages with version history and per-version download links (proxy URL constructed per registry type — cargo, npm, nuget, rubygems, pypi, conda, vsix)
  • [x] User listing and block management in the admin panel (OIDC and Kubernetes-sourced identities, not just static tokens)
  • [x] Config editor with validation and apply button (integrates with hot reload)
    • [x] Read-only warning when the config file is mounted from a Kubernetes ConfigMap, with instructions for applying changes externally
  • [x] Web console redesign (RFC 0003, docs/rfc/0003-ui-rework.md) — full rework of ui/: one catalog surface and one canonical package URL (/packages and /explore used to duplicate the job with two detail URL shapes), an identity-driven shell, a designed first run for empty instances, four first-class list states (empty / filtered-empty / error / denied), a scope-and-count contract on destructive actions, one token source shared with docs/ (ui/src/design/tokens.css, task ui:tokens), WCAG 2.2 AA measured in a browser at four viewports, and French/English localisation (vue-i18n, ui/src/locales/{en,fr}.json, with task ui:i18n:check failing on a hardcoded string). The name BatleHub. and the Monofolio lineage stayed binding; carried out with the Impeccable design skill, DESIGN.md is the deliverable and its deterministic detector is a CI gate. All nine phases landed 2026-08-16
  • [x] Admin composition and the API surface the console is missing — the follow-on RFC 0003 deferred, in five phases. Every documented 200/201 declares a body, enforced by a gate that walks the generated ApiDoc (136 responses carried only a description, so generated clients emitted unknown and the docs site's API reference was blank), ui/src/lib/registry-types.ts and its four hand-written DTO mirrors are gone, and GET /api/v1/me/{quota,downloads,advisories} give a developer their own usage, pulls and advisories without an admin — scoped in the ports rather than the handlers, so no future caller can assemble the query without the filter. / gained the quota meter and the advisories widget on what you recently pulled; the dashboard's trend reads a persisted hourly rollup rather than counters that reset with the process, under a [stats] block that also makes /metrics switchable. All fifteen admin pages went through an Impeccable pass with authority to keep, update, split, merge, remove or add, one verdict per page; the three verdicts it only partly discharged were finished by RFC 0004-bis (RFC docs/rfc/0004-admin-composition-and-api-surface.md)
  • [x] What RFC 0004 left, and the gates that could not see it — the residue RFC 0004 discovered and deliberately deferred, in six phases. Three gates that reported green over conditions they could not observe now observe them: the i18n audit reads component props and <script> assignments rather than an enumerated attribute list, the catalogue test asserts every key is referenced (94 were not), and one merged gate measures the type ramp and display face on every rendered route instead of /admin/* only. The access-check simulator consults the account and IP block stores it used to ignore, so it stops answering allow for an account the adjacent page shows as blocked. Seven API gaps closed and one (A5, a config revert path) declined with its reason recorded. Every admin page gained the component test it never had, the explore cache stopped outliving the identity it was filled for, and fourteen free-text fields that named something the server already knew became Selects or a real combobox. §13 adds four product gaps nothing was tracking, of which the licence gate is built and the other three are specified. /packages stays pinned red against its own design proof until O3 decides which side moves — a visible disagreement rather than a green gate over a page nobody was comparing to anything (RFC docs/rfc/0004-bis-what-rfc-0004-left.md)
  • [x] Each version's own README, stored, rendered and shown (RFC 0007, docs/rfc/0007-package-readmes.md) — the README was received on four code paths and thrown away on all four. It is stored per (registry, name, version) from whichever source the kind has, rendered to sanitised HTML on the server (allow-listed and fuzzed, fuzz_readme_render, because the console serves the console's own origin) and shown under a version selector, with the CLI able to print the source. The page also gained a discovery read: one bounded, cached upstream lookup on the console path, so a package this instance holds no bytes of has a version list and a README instead of "no versions yet". All nine phases landed 2026-08-23
  • [x] What the console owes a reader looking at one package (RFC 0013, docs/rfc/0013-console-answers-for-a-package.md) — the search state survives a click; the version list keeps its filter and page; the selected version is marked in something other than a 1.06:1 fill; a README rendered from markdown can be read as markdown and fenced code keeps its language; the hosts an image may come from are stated; two lists page on the operator's numbers; and the Fetch this version button is no longer offered to a reader the endpoint would refuse — which it now does, having refused nothing before. All thirteen phases landed 2026-08-23
  • [x] Images, search and fetch (RFC 0007-bis, docs/rfc/0007-bis-images-search-and-fetch.md) — an extension's SVG icon renders: the README image proxy's sanitiser became a shared service (fuzz_svg_sanitize) and the marketplace asset endpoint serves the icon sanitised, as an image, under the same sandbox policy, where it used to be an opaque download that left the Extensions view and the console with no icon at all; a document the sanitiser refuses is still an opaque download. And a package can be fetched from the catalogue, not only from its page: an upstream search row carries a button naming the version the upstream returned — Fetch 4.17.21 — running the same download the package page runs, under the caller's own identity and through every gate, the quota and the audit; off with console_fetch = false. Both measured first (tests/heavy/console_fetch.sh, check_svg_icon_view.py)

Documentation ​

  • [x] One documentation tree, and it wears the design system (RFC 0005, docs/rfc/0005-docs-site-design-system.md) — the repository had two trees, website/ published and docs/ unpublished, with four documents maintained in both and drifted by up to 296 lines. They are one tree, docs/, split into spaces by who reads it — guide/, use/, registries/, operations/, contributing/, rfc/ and an unpublished internal/ — and everything that was only in the repository is on the site: the configuration reference, the runbooks, the contributor guides and every RFC under a status banner generated from its own Status row. The site uses the console's tokens (docs/.vitepress/theme/tokens.css, generated), self-hosts its fonts, and task docs:design:rendered measures every published page at two viewports in both renditions. Published URLs did not change. This roadmap page is generated from ROADMAP.md by task docs:roadmap with a drift gate. Landed 2026-08-25
  • [x] The guide is split by audience, and every instruction has one home (RFC 0005-bis) — docs/guide/ is the operator's space and docs/use/ is for the person whose package manager talks to BatleHub; publishing instructions existed twice, sliced two ways, and the registry page is now the home and carries the walkthrough (task docs:audience, task docs:structure). All ten phases landed 2026-08-25
  • [x] The documentation is published in French — 66 pages under /fr/: the operator's guide, the package manager's guide, the registry pages and the operations runbooks. A translation declares in its frontmatter the page it translates and the revision it was translated from, so task docs:i18n:check fails when an English page moves ahead of its translation. contributing/, rfc/ and this roadmap stay in English by decision; an untranslated page is linked at its English URL rather than omitted into a 404
  • [x] Config generator (docs/guide/config-generator.md) — a form on the site that emits a config.toml, its TOML checked by task docs:generator:check against the server's own loader. Since 2026-09-09 a verbs field is a picker over the closed vocabulary rather than a free-text input: the verb lists are pinned by config-generator.test.ts to Action::ALL's as_str arms, so the picker cannot offer a verb the server would refuse (the guide's own table once named three that did not exist), and a verb out of place for its tier — an ecosystem verb in the kind-neutral [grants], or * there, which the server refuses — is flagged in the form

Testing ​

  • [~] Unit tests for all registry adapters and policy evaluation logic — significant coverage added (entities, services, auth, storage router, registry adapters, web middleware, handler guards); ≥80% line coverage enforced by task coverage-check; 2422 tests across 69 suites as of last run (1405 of them lib unit tests); recent additions cover the percent-encoded-registry bypass on both the path_routing = false 404 and the rate limiter, ProxyTrust::replace_from reaching every clone, and the two pending_created branches of load_pending_from_content
  • [x] CLI test suite — 23 unit tests (parse_oidc_paste, is_token_expiring_soon, detect_project_types for all 9 manifest types) + 16 integration tests (registry, package, version yank/unyank/delete, publish, auth, shell completion, Kubernetes login); fixed InMemoryLocalRegistry case-sensitivity bug so yank/delete tests pass end-to-end
  • [x] Protocol conformance fixtures — the client's paths, not ours — crates/web/tests/protocol_conformance.rs: one table per ecosystem of the literal request lines a real package manager sends, each carrying the source it was read from, asserted to route; plus a must_find class for collection endpoints, because a route that returns an empty 200 is indistinguishable from a stub by every other signal. Unserved paths carry not_yet("phase N") and assert the opposite, so the list is a published inventory that can only shrink. This is the priority item in this section, and RFC 0009 is the argument for it: six findings, every one of them shipped green — four endpoints at addresses no client calls, one whose default client reads the single index we do not filter, and one stub the service index advertises as a feature. In each case a test existed and passed, because it was written from the implementation and could not discover that the implementation answers the wrong question (RFC docs/rfc/0009-protocol-coverage.md §5)
  • [x] Integration tests against real upstream registries (gated, opt-in) — the discharge for RFC 0009 §12: the conformance tables encode what we believe each client calls, and until a real npm audit and bundle install are captured against a live instance, that belief is reasoning rather than evidence. Captured, and it was not reasoning: seven ecosystems were run against a real server with real clients (npm, bundler, terraform, dotnet, micromamba, composer, pip, ovsx), finding twelve shipped bugs that every test in the repository passed (RFC 0009 §12). Now scripted, all of them: tests/heavy/{bundler,npm,pypi,openvsx,conda,nuget,composer,terraform}.sh plus marketplace.sh — each starts a real server behind a logging tap, drives the real client and asserts on the wire (task test:heavy, or one task test:<ecosystem>-heavy). They run in CI as heavy-bundler, heavy-marketplace and the heavy-client matrix, with the server under cargo llvm-cov so the client's path counts toward coverage, and nightly because the clients come from outside this repository. Writing the scripts found six more shipped bugs in one pass, none of them a wrong path (RFC 0009 §12.16)
  • [x] A heavy suite per feature RFC, not only per protocol — every RFC that makes a client-facing claim ships with the script that drives the real client through it: tests/heavy/authz.sh (RFC 0015/0017), quarantine.sh (0018, and 0002 at step 8), upstream_audit.sh (0014), mise.sh (0008 §4 with egress denied, and 0019), airgap.sh (0008-bis, ten clients), nvm.sh and sdkman.sh (0010), vsx_login.sh and vsx_view.sh (0011 and 0020, VS Code 1.136's workbench driven in a browser), console_fetch.sh (0007-bis), plus cargo.sh, go.sh, maven.sh and pathproxy.sh for the kinds RFC 0009 left uncovered. A suite is re-run green at an RFC's sign-off and the date is recorded in its §13
  • [ ] Broader fuzzing targets beyond the current seven (RBAC, grant resolution, cache key, deny-latest, release age, README render, SVG sanitiser — fuzz/fuzz_targets/, gated per PR by task fuzz:check)
  • [x] Cover code with SonarCloud — .github/workflows/sonar.yaml runs frontend (vitest lcov) + backend (cargo-llvm-cov lcov, with Postgres/RustFS/Redis services) and uploads both reports to SonarCloud on every push to main

Released under the Apache 2.0 License. Made with ❤️ and too much ☕.