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
twineinlocal/hybridmode - [x] Maven / Gradle — Maven Central-compatible metadata XML + JAR / POM downloads; private publishing via
mvn deployinlocal/hybridmode - [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;
.nupkgand.nuspecdownloads; private publishing viadotnet nuget pushinlocal/hybridmode - [x] Deb / RPM — Debian APT (
type = "deb") and Red Hat YUM/DNF (type = "rpm") repository proxying and private hosting inlocal/hybridmode:.deb/.rpmpublish,Packages/Releaseandrepodata/regeneration, Ed25519 OpenPGP-signed metadata (InRelease/Release.gpg,repomd.xml.asc). Signing is hand-rolled (Ed25519 only) to avoid the bannedrsacrate - [x] JetBrains IDE archives —
type = "jetbrains": proxy-only path-based cache for JetBrains IDE installer archives (default upstreamdownload.jetbrains.com); reuses the genericPathProxyRegistryClient. No private hosting. IDE archives are large (~1-1.7 GB), solimits.max_artifact_size_bytesmust be raised - [x] JetBrains Marketplace —
type = "jetbrains-marketplace": full marketplace emulation for the plugin ecosystem (default upstreamplugins.jetbrains.com) inproxy/local/hybridmode. IDE-facing surface (search, compatible updates,meta.jsonblobs,plugin/download,pluginManager) complete enough foridea.plugins.hostfull replacement, plusupdatePlugins.xmlfor the additiveidea.plugin.hostsflow; marketplace-compatible multipart publish (POST /api/updates/upload) soplugin-repository-rest-client/GradlepublishPluginwork 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 withextensionsGalleryinproduct.jsonand search, install and update through it, plus the OpenVSX REST API (/api/{namespace}/{extension},/api/-/search,…/file/{name}) forovsx. 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 needsanonymousread or an authenticating ingress - [x] Terraform registry — provider and module proxy protocol; private module + provider publishing in
local/hybridmode - [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/v1release 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,gitlabandforgejotake 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,-Requestedand-Previous-Commitsay what the name resolved to and what it answered last time.[registries.refs]sets the branch TTL and what a mutable ref costs (MUTABLE_REFwarned 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 bytests/heavy/mise.sh - [x] Composer — PHP Composer registry (Packagist v2 protocol —
packages.json, p2 metadata, dist downloads); private package publishing via ZIP upload inlocal/hybridmode - [x] Anaconda / Conda — Python data science package registry;
repodata.jsonproxy and channel merging;.tar.bz2and.condapackage parsing; private channel publishing inlocal/hybridmode - [x] Arch Linux / Pacman —
type = "pacman": proxy upstream Arch mirrors and private hosting inlocal/hybridmode:.pkg.tar.{zst,xz,gz}publish (metadata read from.PKGINFO), per-arch<repo>.db/<repo>.filesdatabase regeneration, Ed25519 OpenPGP-signed database (<repo>.db.sig) and packages (.sig+ embedded%PGPSIG%) soSigLevel = Requiredworks. Signing reuses the hand-rolled Ed25519 signer (thersacrate 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). ReusesPathProxyRegistryClientlikejetbrains, but with a mandatory explicitupstreamsentry and a mandatorypath_allowglob 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 thegoproxymodule adapter), and single-binary vendor CDNs (get.helm.sh,binaries.sonarsource.com). Archives can be large, solimits.max_artifact_size_bytesmay need raising. Node (nodejs.org/dist) was on this list and is the reasonnodedistbelow exists:genericmirrors it correctly and can enforce nothing on it, because a path-addressed registry has no version to block.genericstays 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": thenodejs.org/disttree as a typed adapter rather than agenericmirror, so a Node release can be blocked rather than merely cached.index.tabandindex.jsonare filtered listings; the tarballs andSHASUMS256.txtare artifacts under{name}/{version}/{file}. The point is the identity, not the bytes:genericalready 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.tabis also the enforcement chokepoint, becausenvm_remote_versionresolves every install through it, including a fully specified one.SHASUMS256.txtis passed through byte-exact: nvm verifies against it and a sibling.asc/.sigsigns it. Shipped with RFC 0010 phases 1–4 on 2026-09-03, verified bytests/heavy/nvm.shagainst nvm 0.40.3; the console setup snippet and the registry page landed with phase 9 on 2026-09-04 (RFCdocs/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_urlbeside cargo'sindex_url), filtersversions/all,candidates/defaultand the renderedsdk listtable, and refuses a blocked version atcandidates/validateso SDKMAN prints its own "is not a valid … version" rather than failing mid-download. The broker answers302to 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 bytests/heavy/sdkman.shagainst sdkman-cli 5.23.0 (the refusal, the broker redirect, the relayed hook and the cache all observed on the wire);warm_platformsand the.nvmrc/.sdkmanrcinputs ofregistry suggestlanded with it, as did the console entries and registry pages for both toolchain kinds (RFCdocs/rfc/0010-toolchain-managers.md§13.2) - [ ] Generic file mirror,
local/hybridmode (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 besidedeb,rpmandpacman, and the base of most container images. Proxy mode is a path tree per{branch}/{repo}/{arch}withAPKINDEX.tar.gzrelayed 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:apkis the one path kind with a block list, an age gate and a row in explore.local/hybridmode hosts and signs its ownAPKINDEXwith RSA-2048/SHA-256 throughaws-lc-rs, without the bannedrsacrate, and rotates keys additively throughapk_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) intests/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 reasonnodedistleftgeneric:genericcaches 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, sorustup toolchain installfails the way it fails upstream — and the per-target component tarballs with their.sha256siblings are artifacts.RUSTUP_DIST_SERVERis 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}.narinfoper store path (the per-package listing),nar/{hash}.nar.{xz,zst}as artifacts,realisations/for content-addressed derivations.narinfosignatures are Ed25519 (Sig: cache.nixos.org-1:…), the only registry protocol whose signing matches this codebase's constraint natively, solocal/hybridmode signs what it hosts with the shared Ed25519 signer and a client lists the registry's key intrusted-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); theSig:does not coverURL:, 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 inBundleEntry::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-stylesubstitutersentries - [ ] Homebrew (RFC 0027,
docs/rfc/0027-homebrew.md, Draft) —type = "homebrew": two upstreams behind onebrew install. The JSON API (formulae.brew.sh/api, switchHOMEBREW_API_DOMAIN) servesformula.jws.jsonandcask.jws.json, the listing every install resolves through — and it is a JWS signed by Homebrew (PS512, an RSA key shipped inbrewitself, 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, switchHOMEBREW_BOTTLE_DOMAIN) as two read-only OCI endpoints —{formula}/manifests/{version}and{formula}/blobs/sha256:{digest}, anonymousBearer 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, andbrewfails 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 setsHOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK— the registry page has to say that in its first paragraph, and the heavy suite has to prove a403stays a403. Two limits the RFC states:homebrew/corepublishes one version per formula (the API lists no history), so "block a version" is "block the formula until it bumps", closer toDenyLatestRulethan to a block list; and casks and source tarballs are out of reach — brew 6.0.22 rewrites onlyghcr.ioURLs throughHOMEBREW_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 viagh) 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 agenericinstance;localmode would regenerateindex.yamlfrom the DB the waycondaregeneratesrepodata.json. Note that the helm binary (get.helm.sh) is already covered bygeneric— 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 MBindex.yamlnow points almost entirely atoci://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 atjsr.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 thenpmadapter can serve as a second upstream rather than a second kind. Client switches:JSR_URLfor 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 itsdownload_url),/api/discovery, and the artifact tarballs; roles (/api/v1/roles) are proxied read-only behindroles = proxy | index | off, with the limit the RFC found:ansible-galaxy role installbuilds agithub.com/{user}/{repo}/archive/{version}.tar.gzURL itself and only prefers thedownload_urlthe 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 installresolves every dependency through the versions list, so it is the enforcement chokepoint;download_urlis rewritten to this instance or the tarball bypasses the proxy (the TerraformX-Terraform-Getlesson). Every listing is served as a single page with a nullnext: 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'supdated_at, because that field is howansible-galaxydecides whether to drop its day-old copy of the listing.local/hybridmode acceptsansible-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_listinansible.cfg - [ ] Chocolatey and PowerShell Gallery — NuGet v2 (RFC 0032,
docs/rfc/0032-nuget-v2.md, Draft) — thenugetadapter speaks v3 only; the classicPowerShellGetandchoco1.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 ownGetCoreV3factory and does read v3 feeds (it only loses theListResourceoptimisation), while the decisive gap is the upstream —community.chocolatey.orghas no v3 endpoint at all (/api/v3/index.json→ 404), so no value ofupstreamsproxies 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 existingnugetkind rather than a new one — same.nupkg, samePackageId, same block list — with OData rendered from the same data the v3 registration pages are;local/hybridpublishing already exists on v3 andchoco pushuses the samePUT /api/v2/package.chocolatey.organdpowershellgallery.combecome 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-Controlheaders 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 withwarm_packages,warm_latest_n, andwarm_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
502and is never cached (not bypassable);require_metadata = trueadditionally blocks downloads with no advertised checksum (withbypass_roles) - [x] Sigstore / npm provenance verification — the
sigstorescanner of RFC 0018 ([scanners.sigstore],rekor_url,require_for = ["npm"]) verifies npm provenance attestations against Rekor;require_provenance = trueon a registry's security profile turns a missing, invalid or unverifiable attestation into aPROVENANCE_MISSING/PROVENANCE_INVALID/PROVENANCE_UNVERIFIABLEfinding 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 —
RequireSignedReleaseRulereads a best-effortis_signedsignal populated by the GitHub/Forgejo and OpenVSX/VS Code Marketplace adapters;deny_missing_signature+bypass_rolescontrol enforcement - [x] Allowlist of trusted publishers —
trusted_publisherrule; 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_gaterule (allow/blockwith 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_gaterule (min_severity,block/warn-only,bypass_roles) - [x] Licence policy rule —
license_gate: allow/deny SPDX expressions per registry,allow_unknownfor the case where the licence is not yet known,blockvs warn-only andbypass_roleslikecve_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:ArchiveSbomExtractornow returns the declared licence alongside the dependencies from the same parse, in five ecosystems (cargo, npm, maven, pypi, nuget), stored onartifact_sboms.licenseand 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). Alicense_gateon one of the sixteen registry types with no parser raiseslicense-gate.no-extractor, orlicense-gate.denies-everythingwhenblock+allow_unknown = falsewould 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 externalsocket/mlabclients), 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, behindquarantine:readandfindings: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 workersplits it out over thescan_jobsqueue), and the scanner toolchains live only in the worker image (Containerfile.worker). Every phase of §12 landed; the client-facing claims are proven bytests/heavy/quarantine.sh - [ ] Sandbox runtimes (RFC 0022,
docs/rfc/0022-sandbox-runtimes.md, Draft) — where a scanner's process runs is hard-wired tobwrap, and where the host forbids unprivileged user namespaces (Ubuntu 24.04's AppArmor default, most hardened Kubernetes nodes, every rootless container without--userns)bwrapstarts, 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
latestto 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 fromRegistryKind::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 bytests/heavy/upstream_audit.sh - [x] Signed VSIX assets for
openvsx/vscode-marketplaceregistries (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 asMicrosoft.VisualStudio.Services.VsixSignature, the key as…PublicKeyand atGET …/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 bytests/heavy/vsx_view.shin 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)
- [ ] Phase 4, a signature crossing the air gap — deferred, and decided in principle, until a VSIX crosses the gap at all: the bundle is
- [ ] 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
socketandmlabclients 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); abatlehub_upstream_health_degraded{registry}gauge plus atracing::warn!on the healthy→degraded transition; also surfaced inGET /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 whenrequire_metadata = true)
Authentication providers
- [x] Static tokens — plain-text and Argon2id-hashed Bearer tokens in
config.toml;batlehub hash-tokenCLI - [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 a403naming it, rather than dropping it silently;UserTokenAuthProvider::to_identityreturns it, so RFC 0015'sgroup:subjects match a PAT andpat_is_within_ownerno longer compares against a set that can only be empty. The console picks the groups from the caller's own onTokensPage.vueand 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-groupsand shows the snapshot inauth token createandauth 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 noAuthorizationheader (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 = truemints 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
notifycrate) loads a pending reload; admin confirms viaPOST /api/v1/admin/config/pending/applyor 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 (
HEADto 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 — alsoGET /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_changestable; retrievable viaGET /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-reloadUI 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 fromGET /api/v1/admin/config/warnings, returned inline by/config/validateand/config/from-content, and rendered on the Config Reload admin page. Each carries a stablecodeand thepathof the offending config location./config/from-contentreports 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_createdon the reload responses —/config/from-contentanswers200with 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 answering404 No pending reload - [x] Layered config files —
--configis 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 onnamethentype, everything else is replaced, later layers win. A Kubernetes Secret can sit beside a ConfigMap, a0600file 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_CONFIGtakes 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 —
UpstreamDetailCoordinatorremembers which coordinates an upstream answered404for, andclear_absentexists 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[].hostsadds 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 (RFCdocs/rfc/0001-subdomain-routing.md) - [x] Every self-referencing URL reflects the ingress the client used — one
registry_public_basehelper replaced 8 ad-hoc base-URL builders, ~25format!("{base}/proxy/{registry}/…")sites and thebase_urlcontract of 5LocalRegistryServicemethods - [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 governingForwarded/X-Forwarded-Host/X-Forwarded-Proto/X-Forwarded-Foralike, resolved once per request into aPeerTrustverdict 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 = false404 or a registry's rate limit - [x]
RegistryInfo.public_urlonGET /api/v1/registries, used by the Setup Guide and namespace upload snippets — the.netrcblock 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 Terraformsourcedrops the registry segment on a host-routed registry, where it is no longer part of the path - [x] Helm
ingress.extraHosts+ documentedconfig.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 a503that names itself and is recorded, so the next bundle's contents are produced by the estate rather than guessed.batlehub mise plan|seed|export|importturnsmise.lockinto 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 bytests/heavy/mise.sh§4:mise installagainst a second instance with egress denied (RFCdocs/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, sonpm install left-pad@1.3.0andpip install six==1.17.0stopped at a503on 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 itX-BatleHub-Listing: synthesisedwith the count inX-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'srepodata.json, NuGet's registration pages, Composer'sp2) 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 = falserestores the old refusal. All six phases landed 2026-09-05; ten clients prove the whole intests/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 suggestalready 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
PUTAPI - [x] Maven — private artifact publishing via
mvn deploy; POM-triggered three-phase publish; JAR/checksum pre-upload; dynamically generatedmaven-metadata.xmlfrom DB;localandhybridmodes - [x] Terraform — private module publishing (tar.gz upload,
X-Terraform-Getredirect); private provider publishing (version manifest + per-platform binary upload);localandhybridmodes - [x] PyPI — private wheel / sdist publishing via
twine; Simple API served from DB;localandhybridmodes - [x] Conda — private channel with
repodata.jsongeneration from DB;.tar.bz2and.condapackage upload;localandhybridmodes
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 onlyput_grantcaller was the ownership projection, which writes package rows carrying exactlyreleases:publish+owners:read+owners:write, soreleases:readon one package for one group — RFC 0015 §4.4's own opening example — was unexpressible and the version tier, read on everyauthorize, could not be populated at all. Both halves shipped in one change, which the RFC requires rather than recommends:grants:read/grants:writein the vocabulary and held byrole:adminat the instance tier,GrantAdminServiceas the single write funnel (expansion at write, the ownership-verb409, the redundancy and spent-coordinate warnings), three admin routes,batlehub admin grants list|set|rm, and a console panel on the package detail page — andfilter_by_grantsinload_visible_versions, which gavefilter_listingandpackage_visibilitytheir first caller. Rule 3 landed with them:DocumentAudiencenow 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 areleases:list-without-releases:readcaller sees, and that is now what the document says. And §6.3's "explainneeds no change, it resolves through the same path" was false:resolution_pathcomposes the config-declared tiers, and the stored ones are appended byauthorize_grantsafter a short-circuit no diagnostic reaches, soexplainandaccess-checkboth answered deny where the server answers allow —explainwhile namingpackage:…/version:…intiers_walked. Both now takeresolution_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 onReadable::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:curlasks/infodirectly and Bundler does not, it resolves from/versions, and an empty one sends it to the legacyspecs.4.8.gzindex this server answers404. Failed closed, so no disclosure — but §4.4 rule 2 was unreachable in practice.Readable::with_version_grantsadmits the name;tests/heavy/authz.sh rubygemsnow assertsbundle installresolves to the granted2.0.0while3.0.0exists and is newer - [x] Artifact signing framework: publish with
X-Artifact-Signature/X-Signature-Typeheaders; signature stored in DB and returned on download; optionalsigning.requiredenforcement. Optional download-time verification of storeded25519signatures against configuredsigning.trusted_keys(signing.verify_on_download); Ed25519 only, since thersacrate is banned - [x] Ownership and team management: per-package owner table (user/group, admin/maintainer roles);
initialize_owneron first publish;can_publishcheck 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.0cannot 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_daysas a union of vetoes — a version survives if any matches, so wrong configuration fails toward keeping.dry_runon 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_servere-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 with502and 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; agithub/gitlab/forgejoregistry 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}/importruns one now (cache:warmto ask; the configured principal's ownreleases:publishto do it),interval_secsruns it on a schedule, a version the registry already holds is skipped,latestmeans 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 rulesbatlehub publishalways used, now shared with the server. Phases 1–3 and 5 landed with v1.2.0 on 2026-09-09 (batlehub-clicommand, 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-clibinary incli/; global flags--profile,--server,--token,--registry,--jsonand 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_metaauto-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|versionsandbatlehub-cli owners list|add|remove - [x] Authentication support for static tokens and token management —
batlehub-cli auth whoamiandtoken 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 suggestscans the working directory (mise.toml/mise.locktool 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 intoconfig.toml.--client-envadditionally 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.tomlwith named profiles;batlehub-cli config init|show|set - [x] Config file output for both CI automation and human use —
config initinteractive wizard;--jsonflag 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.lockas 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-pathandproxy 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 explainandauthz shadow(RFC 0015);admin grants list|set|rm(RFC 0017);why,admin flags listandadmin exposure(RFC 0018 / 0002);vsx keygenandvsx 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 inpatches/che-code/reads the same file and retries once on401; 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.shdrives the real VS Code core through it, and the editor extension lives in the separatebatlehub-vsxrepository
- [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) —
- [x] A TUI mode for interactive use —
batlehub-cli tuilaunches aratatui/crosstermterminal UI- [x] List of registries with search and filter capabilities —
registry_listscreen - [x] Per-registry package explorer with version details and management actions —
package_listscreen (live search/filter) +package_detailscreen (yank / unyank keybindings) - [x] Interactive prompts for publishing new versions —
publish_formscreen with auto-detected name and version fields - [x] Help setup registry for a current project by scanning local files — TUI
SetupWizardscreen (sfrom 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_pathpersisted in profile; auto-refresh on startup; TUILoginscreen (Lfrom registry list) with three-tab method selector
- [x] List of registries with search and filter capabilities —
SBOM support
- [x] Proxy existing SBOMs from upstreams that provide them (GitHub dependency graph API, npm
bom.json) — enabled byfetch_upstream = truein[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 = truein[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 toPOST /api/v1/flags/{source}withX-Hub-Signature-256, one item per coordinate at an exact version or*, andDELETE /api/v1/flags/{source}/{external_id}revokes it. The effect is capped by the source'smax_effectand scoped to the registries it names. On a registry with[registries.security]the flag becomes a finding of kindSocVerdict: ahard_blockdenies the stored verdict at once and re-derives on rescan, agateis judged against the registry'smax_severity, and the only relief is aGateExemption; on a registry without a profile the same store is read by a rule of its own beside the block list. The inboundsecurity.verdictwebhook is the degenerate case of the same push. Landed 2026-09-04, npm through the whole lifecycle 2026-09-06 (tests/heavy/quarantine.shstep 8) - [x] Vulnerability exposure reporting (same RFC) —
GET /api/v1/admin/exposurejoins 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 listandbatlehub admin exposureare 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 auditand 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 ofui/: one catalog surface and one canonical package URL (/packagesand/exploreused 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 withdocs/(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, withtask ui:i18n:checkfailing on a hardcoded string). The nameBatleHub.and the Monofolio lineage stayed binding; carried out with the Impeccable design skill,DESIGN.mdis 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/201declares a body, enforced by a gate that walks the generatedApiDoc(136 responses carried only adescription, so generated clients emittedunknownand the docs site's API reference was blank),ui/src/lib/registry-types.tsand its four hand-written DTO mirrors are gone, andGET /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/metricsswitchable. 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 (RFCdocs/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 answeringallowfor 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 becameSelects 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./packagesstays 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 (RFCdocs/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: anupstreamsearch 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 withconsole_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 anddocs/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 unpublishedinternal/— 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 ownStatusrow. The site uses the console's tokens (docs/.vitepress/theme/tokens.css, generated), self-hosts its fonts, andtask docs:design:renderedmeasures every published page at two viewports in both renditions. Published URLs did not change. This roadmap page is generated fromROADMAP.mdbytask docs:roadmapwith 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 anddocs/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, sotask docs:i18n:checkfails 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 aconfig.toml, its TOML checked bytask docs:generator:checkagainst 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 byconfig-generator.test.tstoAction::ALL'sas_strarms, 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 thepath_routing = false404 and the rate limiter,ProxyTrust::replace_fromreaching every clone, and the twopending_createdbranches ofload_pending_from_content - [x] CLI test suite — 23 unit tests (
parse_oidc_paste,is_token_expiring_soon,detect_project_typesfor all 9 manifest types) + 16 integration tests (registry, package, version yank/unyank/delete, publish, auth, shell completion, Kubernetes login); fixedInMemoryLocalRegistrycase-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 amust_findclass for collection endpoints, because a route that returns an empty200is indistinguishable from a stub by every other signal. Unserved paths carrynot_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 (RFCdocs/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 auditandbundle installare 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}.shplusmarketplace.sh— each starts a real server behind a logging tap, drives the real client and asserts on the wire (task test:heavy, or onetask test:<ecosystem>-heavy). They run in CI asheavy-bundler,heavy-marketplaceand theheavy-clientmatrix, with the server undercargo llvm-covso 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.shandsdkman.sh(0010),vsx_login.shandvsx_view.sh(0011 and 0020, VS Code 1.136's workbench driven in a browser),console_fetch.sh(0007-bis), pluscargo.sh,go.sh,maven.shandpathproxy.shfor 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 bytask fuzz:check) - [x] Cover code with SonarCloud —
.github/workflows/sonar.yamlruns frontend (vitest lcov) + backend (cargo-llvm-cov lcov, with Postgres/RustFS/Redis services) and uploads both reports to SonarCloud on every push tomain