A self-hostable, multi-format artifact & package manager.
One service that replaces JFrog Artifactory + Harbor + a standalone supply-chain scanner — speaking 37 built-in registry formats natively, with built-in malware/vulnerability scanning, policy gates, and multi-tenant RBAC.
Hootifactory is a single Rust service that gives your team a private home for every kind of build artifact — npm packages, Docker/OCI images, Python wheels, Helm charts, Cargo crates, Maven/Ivy/P2 artifacts, Debian/RPM/Alpine/Arch packages, Terraform modules/providers, Nix binary-cache objects, Git LFS blobs, and many more — behind one auth model, one storage layer, and one scanning pipeline. Point your existing npm / docker / pip / cargo / helm / mvn CLIs at it; no custom client required.
# The whole stack — API, web UI, workers, Postgres, MinIO — in one command:
docker compose --profile app up --build
# → web UI + registry on http://localhost:3000- One tool, every ecosystem. Stop running a separate registry per language. 37 built-in registry formats share the same repositories, tokens, quotas, and audit log.
- Drop-in for the tools you already use. Adapters speak each ecosystem's native protocol. Client paths are verified end-to-end against the real
npm,docker,oras,helm,pip/twine,go,cargo,dotnet,gem/bundler,composer,mvn,sbt,apt,dnf,pacman,apk,R,conda,conan,opam,luarocks,dart pub,swift,nix,vagrant,puppet,knife,git-lfs,pod,ansible-galaxy,curland more clients, driven through pinned container images (seetests/e2e/README.mdfor the full coverage matrix and the few Windows/HTTPS-only formats that can't be CLI-tested on Linux). - Secure by default. Malware + vulnerability scanning with audit/enforce policy gates, SSRF-guarded proxies, content-addressable immutable storage, and a production config guard that refuses to boot with dev secrets.
- Genuinely multi-tenant. Organizations, role-based access control with hard org-boundary enforcement, scoped API tokens, and OIDC group→role mapping.
- Pluggable to the core. Every format and every scanner is an independent package behind a small SDK. Adding one is a new package plus a one-line manifest entry — the app core never names a concrete format or scanner.
- Supported formats
- Quick start
- Using it: publish & install
- Architecture
- Repositories: hosted, proxy, virtual
- Supply-chain scanning & policy gates
- Security & multi-tenancy
- Management API
- Web UI
- Observability
- Configuration
- Deployment
- Testing & CI
- Contributing
- License
The source of truth is REGISTRY_PLUGIN_MANIFEST, which currently registers 37 concrete built-in registry plugins. All built-ins support hosted repositories. Virtual repositories are available for every built-in except Git LFS today. Proxy (pull-through cache) is implemented where the adapter exposes proxyIngest: npm, Generic/raw, Conda, Puppet Forge, and Chef Supermarket.
Docker, OCI artifacts, and Helm all share the OCI Distribution /v2/ endpoint (the docker plugin, aliased as oci/helm). Other aliases are module ids too: rpm -> yum/dnf, ansible -> galaxy, gitlfs -> lfs, generic -> raw, alpine -> apk, arch -> pacman, puppet -> forge, and chef -> supermarket.
| Ecosystem | Format | Module id(s) | Registry path |
|---|---|---|---|
| JavaScript | npm | npm |
/npm/<org>/<repo>/ |
| Containers | Docker / OCI artifacts / Helm OCI | docker, oci, helm |
/v2/<org>/<repo>/ |
| Python | PyPI | pypi |
/pypi/<org>/<repo>/ |
| Go | Go modules | go |
/go/<org>/<repo>/ |
| Rust | Cargo | cargo |
/cargo/<org>/<repo>/ |
| .NET | NuGet (v3) | nuget |
/nuget/<org>/<repo>/v3/index.json |
| Ruby | RubyGems | rubygems |
/rubygems/<org>/<repo>/ |
| PHP | Composer | composer |
/composer/<org>/<repo>/ |
| R | CRAN | cran |
/cran/<org>/<repo>/ |
| JVM | Maven | maven |
/maven/<org>/<repo>/ |
| JVM | Ivy | ivy |
/ivy/<org>/<repo>/ |
| Eclipse | P2 | p2 |
/p2/<org>/<repo>/ |
| Debian/Ubuntu | APT | apt |
/apt/<org>/<repo>/ |
| RHEL/Fedora | RPM (YUM/DNF) | rpm, yum, dnf |
/rpm/<org>/<repo>/ |
| Alpine Linux | APK | alpine, apk |
/alpine/<org>/<repo>/ |
| Arch Linux | Pacman | arch, pacman |
/arch/<org>/<repo>/ |
| Nix | Nix binary cache | nix |
/nix/<org>/<repo>/ |
| Dart/Flutter | Pub | pub |
/pub/<org>/<repo>/ |
| Swift | Swift package registry | swift |
/swift/<org>/<repo>/ |
| Windows | Chocolatey | chocolatey |
/chocolatey/<org>/<repo>/ |
| Windows | winget | winget |
/winget/<org>/<repo>/ |
| macOS/Linux | Homebrew | homebrew |
/homebrew/<org>/<repo>/ |
| Windows | Scoop | scoop |
/scoop/<org>/<repo>/ |
| Apple | CocoaPods | cocoapods |
/cocoapods/<org>/<repo>/ |
| BEAM | Hex | hex |
/hex/<org>/<repo>/ |
| Haskell | Hackage | hackage |
/hackage/<org>/<repo>/ |
| OCaml | OPAM | opam |
/opam/<org>/<repo>/ |
| Lua | LuaRocks | luarocks |
/luarocks/<org>/<repo>/ |
| C/C++ | Conan | conan |
/conan/<org>/<repo>/ |
| Data science | Conda | conda |
/conda/<org>/<repo>/ |
| Infrastructure | Terraform modules/providers | terraform |
/terraform/<org>/<repo>/ |
| Infrastructure | Vagrant boxes | vagrant |
/vagrant/<org>/<repo>/ |
| Automation | Ansible Galaxy | ansible, galaxy |
/ansible/<org>/<repo>/ |
| Git | Git LFS | gitlfs, lfs |
/lfs/<org>/<repo>/ |
| Generic | Generic/raw blobs | generic, raw |
/generic/<org>/<repo>/ |
| Configuration | Puppet Forge | puppet, forge |
/puppet/<org>/<repo>/ |
| Configuration | Chef Supermarket | chef, supermarket |
/chef/<org>/<repo>/ |
Registries are immutable: re-publishing an existing
name@versionreturns409 Conflict.
cp .env.example .env
docker compose --profile app up --buildThis builds the single Hootifactory image and starts the API + web UI (:3000), the scan and mail workers, Postgres, MinIO, and Mailpit (mail catcher, UI on :8025). The stack intentionally runs with well-known demo credentials.
⚠️ The compose stack is a localhost demo, not a deployment. Ports bind to127.0.0.1, credentials are well-known dev defaults, and scanning runs heuristic-only. For production, provide real secrets — see Deployment.
cp .env.example .env
docker compose up -d postgres minio minio-setup mailpit
# Postgres + MinIO + Mailpit (infra only)
cargo run -p hooti-migrate # apply database migrations
cargo run -p xtask -- seed # creates demo org + owner; configurable with SEED_*
cargo run -p hooti-api # API + web UI on :3000
cargo run -p hooti-scan-worker # scan worker (optional; needs SCANNER_ENABLED=true)
cargo run -p hooti-mail-worker # mail worker (Mailpit UI on :8025)- Open the web UI and register your account.
- Create a repository — pick a format module (e.g.
npm) and a name (e.g.libs). - Tokens → New token — mint a scoped
hoot_…API token. Copy the secret once (it's shown a single time). - Use that token as
$TOKENin the snippets below.
The examples below assume a hosted repo libs in org acme, a base URL of http://localhost:3000, and a scoped API token in $TOKEN. Drop --plain-http / --trusted-host / allowInsecureConnections once your instance is served over HTTPS.
This section documents the most common client workflows. The full built-in format set is listed above; newer adapters follow the same /<mount>/<org>/<repo>/ repository shape, with protocol-specific routes owned by modules under crates/registries/src/formats/.
npm — npm / yarn / pnpm
# .npmrc (project-local or ~/.npmrc)
cat > .npmrc <<EOF
registry=http://localhost:3000/npm/acme/libs/
//localhost:3000/npm/acme/libs/:_authToken=$TOKEN
EOF
npm publish # publish the current package
npm install my-pkg@1.0.0 # install from the repo
npm dist-tag add my-pkg@1.0.0 betaAuth is the literal _authToken keyed on the registry host:path; the trailing slash on the registry URL matters. Scoped packages (@acme/...) and npm whoami / search / pack work too.
Docker — docker
# Log in with your account (or use __token__ as the username and $TOKEN as the password)
echo "$PASSWORD" | docker login localhost:3000 -u <username> --password-stdin
docker tag myapp:1.0 localhost:3000/acme/libs/app:1.0
docker push localhost:3000/acme/libs/app:1.0
docker pull localhost:3000/acme/libs/app:1.0
# or pin to an immutable digest:
docker pull localhost:3000/acme/libs/app@sha256:<digest>Image refs nest the image name under the repo: <host>/<org>/<repo>/<image>:<tag>. Public repos allow anonymous pull; private repos require auth.
OCI artifacts — oras
oras login --plain-http localhost:3000 -u __token__ -p $TOKEN
# Push an arbitrary file as an OCI artifact
oras push --plain-http localhost:3000/acme/libs/demo:v1 \
--artifact-type application/vnd.acme.artifact \
payload.txt:application/vnd.acme.payload
# Attach an SBOM as a referrer to an existing subject
oras attach --plain-http --distribution-spec v1.1-referrers-api \
--artifact-type application/vnd.acme.sbom \
localhost:3000/acme/libs/demo:v1 sbom.json:application/vnd.acme.sbom+json
oras pull --plain-http localhost:3000/acme/libs/demo:v1 -o ./out
oras discover --plain-http --distribution-spec v1.1-referrers-api localhost:3000/acme/libs/demo:v1Referrers require --distribution-spec v1.1-referrers-api.
Helm — helm (OCI)
helm registry login localhost:3000 -u token -p "$TOKEN" --plain-http
helm package mychart # → mychart-0.1.0.tgz
helm push mychart-0.1.0.tgz oci://localhost:3000/acme/libs --plain-http # NOTE: no chart name on push
helm pull oci://localhost:3000/acme/libs/mychart --version 0.1.0 --plain-http # chart name IS required on pull
helm show chart oci://localhost:3000/acme/libs/mychart --version 0.1.0 --plain-httpThe push target omits the chart name; pull/show/template require it.
PyPI — twine / pip
# ~/.pypirc — uploads go to /legacy/, installs read /simple/
[distutils]
index-servers = hootifactory
[hootifactory]
repository = http://localhost:3000/pypi/acme/libs/legacy/
username = __token__
password = $TOKENtwine upload -r hootifactory dist/*
pip install \
--index-url http://__token__:$TOKEN@localhost:3000/pypi/acme/libs/simple/ \
--trusted-host localhost \
my-pkg==1.0.0Go modules — go (GOPROXY)
export GOPROXY=http://localhost:3000/go/acme/libs
export GOSUMDB=off # modules are private to this instance
# private repo? add credentials to ~/.netrc:
# machine localhost login __token__ password <token>
go mod download example.com/mod@v1.0.0
go list -m -versions example.com/modGo has no native publish command — upload a module with a multipart PUT; the mod field and the zip's go.mod must both declare the same module path as the upload URL:
curl -fSs -X PUT -H "Authorization: Bearer $TOKEN" \
-F $'mod=module example.com/mod\n\ngo 1.20\n' \
-F 'zip=@m.zip;type=application/zip' \
http://localhost:3000/go/acme/libs/example.com/mod/@v/v1.0.0Cargo — cargo (sparse registry)
# .cargo/config.toml — note the trailing slash
[registries.hooti]
index = "sparse+http://localhost:3000/cargo/acme/libs/"export CARGO_REGISTRIES_HOOTI_TOKEN=$TOKEN # env var = CARGO_REGISTRIES_<NAME>_TOKEN
cargo publish --registry hooti
# consume in Cargo.toml:
# mycrate = { version = "1.0.0", registry = "hooti" }
cargo fetchNuGet — dotnet / nuget (v3)
<!-- NuGet.Config — <clear/> drops the implicit nuget.org default -->
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="hootifactory"
value="http://localhost:3000/nuget/acme/libs/v3/index.json"
allowInsecureConnections="true" />
</packageSources>
</configuration>dotnet nuget push MyPkg.1.0.0.nupkg --api-key $TOKEN \
--source http://localhost:3000/nuget/acme/libs/v3/index.json
dotnet add package MyPkg --version 1.0.0Note: a NuGet version range resolves to the lowest in-range version.
Maven — mvn / Gradle
<!-- settings.xml — pass as BOTH -s and -gs (Maven 3.8.1+ blocks plain-http via a bundled mirror) -->
<settings>
<servers>
<server>
<id>hooti</id>
<username>__token__</username>
<password>$TOKEN</password>
</server>
</servers>
</settings>mvn -B deploy:deploy-file -Dfile=app.jar \
-DgroupId=com.hooti -DartifactId=app -Dversion=1.0.0 -Dpackaging=jar \
-DrepositoryId=hooti -Durl=http://localhost:3000/maven/acme/libs \
-s settings.xml -gs settings.xml
mvn -B dependency:get -Dartifact=com.hooti:app:1.0.0 \
-DremoteRepositories=hooti::default::http://localhost:3000/maven/acme/libs \
-s settings.xml -gs settings.xmlRubyGems — gem / bundler
# ~/.gem/credentials
mkdir -p ~/.gem && printf -- "---\n:hootifactory: %s\n" "$TOKEN" > ~/.gem/credentials
chmod 600 ~/.gem/credentials
gem build mygem.gemspec
gem push mygem-1.0.0.gem --key hootifactory --host http://localhost:3000/rubygems/acme/libs# Gemfile — Bundler resolves from the compact index and verifies checksums
source "http://localhost:3000/rubygems/acme/libs/"
gem "mygem", "1.0.0"Composer — composer
composer config repositories.hootifactory composer http://localhost:3000/composer/acme/libs
composer config repositories.packagist.org false
composer config --global --auth http-basic.localhost:3000 token $TOKEN # token = password
composer require hoot/widget:1.0.0Composer has no native publish — PUT the dist zip with a ?version= query param:
curl -sf -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/zip" \
--data-binary @widget.zip \
"http://localhost:3000/composer/acme/libs/packages/hoot/widget?version=1.0.0"APT (Debian/Ubuntu) — apt / dpkg
# Add the source (unsigned repo → [trusted=yes]); a private repo embeds the token as basic auth
echo "deb [trusted=yes] http://token:$TOKEN@localhost:3000/apt/acme/libs stable main" \
| sudo tee /etc/apt/sources.list.d/hootifactory.list
sudo apt-get update && sudo apt-get install -y hootpkgPublish by PUTting a .deb into the pool with the target suite/component; the server generates Release/Packages on the fly:
dpkg-deb --root-owner-group -Zgzip --build ./pkg out.deb
curl -sf -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.debian.binary-package" --data-binary @out.deb \
"http://localhost:3000/apt/acme/libs/pool/main/h/hootpkg/hootpkg_1.0.0_amd64.deb?suite=stable&component=main"RPM (RHEL/Fedora) — dnf / yum
sudo tee /etc/yum.repos.d/hootifactory.repo <<EOF
[hootifactory]
name=Hootifactory
baseurl=http://localhost:3000/rpm/acme/libs
enabled=1
gpgcheck=0
EOF
sudo dnf makecache && sudo dnf install -y helloPublish by PUTting the .rpm (identity is read from the RPM header); the server builds repodata/ deterministically:
curl -sf -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/x-rpm" \
--data-binary @hello-1.2.3-4.el9.x86_64.rpm \
http://localhost:3000/rpm/acme/libs/packages/hello-1.2.3-4.el9.x86_64.rpmPub (Dart/Flutter) — dart pub
# pubspec.yaml of the package you publish
publish_to: http://localhost:3000/pub/acme/libsdart pub token add http://localhost:3000/pub/acme/libs # paste $TOKEN when prompted
dart pub publish# consume as a hosted dependency
dependencies:
mypkg:
hosted:
name: mypkg
url: http://localhost:3000/pub/acme/libs
version: ^1.0.0Swift — swift package-registry
swift package-registry set http://localhost:3000/swift/acme/libs
swift package-registry login http://localhost:3000/swift/acme/libs --token "$TOKEN"
swift package-registry publish acme.libs 1.0.0 --url http://localhost:3000/swift/acme/libs
# consume by package identifier in Package.swift:
# .package(id: "acme.libs", from: "1.0.0")
swift package resolveChocolatey — choco
choco source add -n hootifactory -s "http://localhost:3000/chocolatey/acme/libs/" -u token -p "$TOKEN"
choco apikey add -s "http://localhost:3000/chocolatey/acme/libs/" -k "$TOKEN"
choco push mypkg.1.0.0.nupkg -s "http://localhost:3000/chocolatey/acme/libs/" --api-key "$TOKEN"
choco install mypkg --version 1.0.0 -s "http://localhost:3000/chocolatey/acme/libs/" -u token -p "$TOKEN"Serves a NuGet v2 (OData) feed; push auth uses the x-nuget-apikey header, reads use HTTP Basic.
winget — winget
winget source add --name hootifactory --type "Microsoft.Rest" `
--arg "http://localhost:3000/winget/acme/libs/"
winget install Acme.Tool --version 1.0.0 --source hootifactoryPublishing is a Hootifactory extension (public winget REST is read-only) — PUT a multipart manifest + installer; the URL's PackageIdentifier must equal Publisher.PackageName.
Homebrew — brew
export HOMEBREW_API_DOMAIN="http://localhost:3000/homebrew/acme/libs/api"
export HOMEBREW_BOTTLE_DOMAIN="http://localhost:3000/homebrew/acme/libs/bottles"
brew install mytoolPublish bottles via a multipart PUT to /api/formula/<name>/<version>/<tag> (a bottle file part + optional formula JSON).
Scoop — scoop
scoop bucket add hootifactory "http://localhost:3000/scoop/acme/libs"
scoop install mytoolPublish via a multipart PUT to /<app> with a manifest JSON part (omitting url/hash — the server derives them from the stored, scanned blob) and an artifact file part.
Hootifactory is a Rust workspace where every artifact format and every scanner is an independent crate behind a small trait contract. The application core never names a concrete format or scanner — each is named in exactly one place: its runtime loader's static manifest.
crates/
api/ registry HTTP server + management API + web host (Axum)
scan-worker/ async scanning pipeline (durable Postgres outbox)
mail-worker/ transactional email (durable Postgres queue)
web/ management UI (Askama SSR, embedded assets)
config/ types/ core/ db/ storage/ auth/ contracts/ queue/ observability/
scan-core/ email/
registry/ protocol-neutral registry plugin SDK (trait + helpers)
registry-platform/ agnostic platform use-cases, sliced: routing · runtime · repositories ·
content · inventory · packages · governance · assets
registry-runtime/ static plugin manifest + config-driven loader
registries/ src/formats/<format>/ ← one module per built-in registry format
scanner/ scanner plugin SDK (ScannerPlugin trait + registry + runners)
scanners/ scanner engines + static manifest ← one module per scanner
**The plugin model**
- Two SDK crates define the contracts: `registry` (the `RegistryPlugin` trait — `routes()`, `required_permission()`, `handle()`, plus optional virtual/proxy hooks) and `scanner` (the `ScannerPlugin` trait). A concrete plugin never depends on application infrastructure.
- Registry plugins are **declarative**: a plugin supplies a mount segment, capability flags, route entries with agnostic flags (`searchable`, `metadata_mergeable`, `service_index`, …), a `(method, route) → permission` mapping, and optional metadata/search/proxy hooks. The platform owns HTTP, routing, auth/RBAC, content-addressable storage, and scan execution.
- Registration is a **static manifest** (`registry-runtime` / `scanners`) — the single place that imports concrete plugins — optionally narrowed at runtime by the `REGISTRY_PLUGINS` / `SCANNERS` operator allowlists. Aliases are module ids, not packages (`docker` -> `oci`/`helm`, `rpm` -> `yum`/`dnf`, `generic` -> `raw`, and so on).
- **Adding a format = a new `registries` format module + one manifest line.** No edit to the app core, the boundary checker, or any sibling plugin.
**Enforced boundaries.** `cargo run -p xtask -- boundaries` discovers plugin crates from the workspace and fails the build if any app or agnostic crate imports a concrete format/scanner, re-acquires format-specific identity (e.g. hardcoded `/v2/` grammar or OCI-ish identifiers), or declares workspace dependencies that drift from what it imports. The boundary check is the source of truth for enforced module rules.
## Repositories: hosted, proxy, virtual
Every repository has a `kind`, dispatched by a single exhaustive switch:
- **Hosted** — read/write locally; the only kind that accepts publishes.
- **Proxy** (pull-through cache) — read-only; on a local miss it mirrors from a configured upstream **through the format plugin's `proxyIngest`** (never transparent passthrough), so upstream bytes still pass scan policy, quotas, and retention. Implemented today for npm, Generic/raw, Conda, Puppet Forge, and Chef Supermarket.
- **Virtual** (group/aggregate) — read-only; available for plugins that advertise `virtualizable` (currently every built-in except Git LFS). It fans out over ordered member repos (bounded-concurrent, default 8; member cap 32), returns the first non-error response or merges metadata/search, and **rewrites member mount paths back to the virtual mount** so clients keep routing through it. Each member is authorized independently.
**Content-addressable storage.** Artifact bytes live in an S3-compatible store (S3 / MinIO / R2) keyed by `sha256` with two-level prefix fan-out. Dedup is two-tiered: storage-level (skip the write if the digest exists) and DB-level (a shared `blobs` row with per-repo `blob_refs` and reference counting). Org storage quota is charged **once** per digest per org. A grace-period sweeper reclaims unreferenced blobs from both the DB and S3.
**SSRF protection.** All upstream fetches go through `safeFetch`, which pins connections to a resolved **public** IP and re-validates every redirect hop. Loopback, RFC1918, link-local/metadata (`169.254.169.254`), CGNAT, and IPv4-in-IPv6 forms are blocked — and a public hostname that resolves to a private address is rejected. `REGISTRY_ALLOW_PRIVATE_UPSTREAMS=true` (dev/test only) is **refused outright in production**.
## Supply-chain scanning & policy gates
Scanning is **off by default** (`SCANNER_ENABLED=false`). When enabled, every published artifact is scanned asynchronously through a durable Postgres **scan outbox**: the registry upsert records an outbox row, and the `scan-worker` runs its own claim/process loop (`FOR UPDATE SKIP LOCKED` — *not* pg-boss) with retry/backoff, a stuck-scan reclaimer, and idempotent rescans.
**Scanners** are plugins dispatched purely by `inputKind` (`stream` / `content` / `dependencies`):
| Scanner | Detects | Notes |
|---|---|---|
| `heuristic-malware` | malware | **always-on baseline**, offline (EICAR signature, streaming) |
| `heuristic-deps` | vulnerabilities | **always-on baseline**, offline (built-in advisory DB) |
| `grype` | vulnerabilities | Anchore Grype, sandboxed Docker CLI |
| `trivy` | vulnerabilities | Aqua Trivy, CLI or client/server (`TRIVY_SERVER_URL`) |
| `clamav` | malware | `clamscan` or ClamAV REST (`CLAMAV_REST_URL`) |
| `osv` | vulnerabilities | OSV.dev — **network, opt-in** (`SCANNER_OSV`) |
The two heuristic baselines are **irreducible**: the `SCANNERS` allowlist can narrow the external set but can never disable the offline malware/advisory gate. External CLI scanners run in **hardened Docker sandboxes** (`--network none --read-only --cap-drop ALL --security-opt no-new-privileges`, non-root, tmpfs, memory/cpu/pids caps) pinned to `@sha256:` digests — enforced at startup in production.
> ⚠️ **`SCANNER_CLI_RUNTIME=host` removes all of that sandboxing.** In host mode the scanner binaries (`grype`, `trivy`, `clamscan`) execute directly on the worker host: no network isolation, no read-only filesystem, no capability drops, no memory/CPU/pids caps — only `SCANNER_TIMEOUT_MS` bounds a run. Scanners parse **untrusted artifact bytes**, so a malicious artifact exercises those parsers with the worker's full privileges. Use `docker` (the default) or `auto` with a reachable Docker daemon in production; note that `auto` falls back to host binaries when Docker is unreachable. The scan-worker logs a startup warning whenever the resolved runtime is unsandboxed host execution.
**Policy gates.** Findings are evaluated against the repo's most-specific scan policy:
- **`audit`** (default) — a violating artifact is **quarantined**; only `blocked` artifacts are withheld.
- **`enforce`** — a violating artifact is **blocked**, and serving is **fail-closed**: bytes stay unavailable until a scanner *positively* marks the artifact clean (the stuck-scan reclaimer prevents a dead worker from permanently wedging downloads).
## Security & multi-tenancy
- **Organizations + RBAC.** A single authoritative `can()` decision function backs every access check. Four roles (`viewer` → `developer` → `admin` → `owner`); `admin` and `owner` share the same action set (owner outranks only for grant ceilings / management). Anonymous access is allowed *only* for `read` on `public` repositories/packages/artifacts.
- **Hard org boundary.** A token is bound to its issuing org on **every** call — `resource.orgId` is resolved from the DB, never trusted from the request path. A cross-org token request is denied outright.
- **Scoped API tokens.** Opaque `hoot_`-prefixed secrets (256-bit), stored only as a SHA-256 hash. Tokens carry structured **grants** (a hard ceiling — the grant *and* the resolved role must both allow an action), support repository glob patterns, expiry, rotation, and revocation, and can never exceed their owner's current role.
- **OIDC SSO.** PKCE + signed/expiring state; IdP groups map to per-org roles, re-synced transactionally on every login (removing a group revokes access). Auto-provisioning requires a verified email claim.
- **OCI bearer tokens.** Short-lived RS256 JWTs (`REGISTRY_JWT_*`), algorithm-pinned against alg-confusion, with the access claim shape validated so a malformed claim degrades to deny-all.
- **Hardening.** Argon2id passwords with constant-time login, DB-backed atomic login/registration/reset throttling (hashed bucket keys, multi-replica safe), session-cookie CSRF rejection on cross-origin writes, strict security headers (CSP, HSTS in prod, `no-store` on credentialed/registry paths), in-flight upload-byte admission control, and a fire-and-forget **audit log** on every mutation.
- **Production secret guard.** With `NODE_ENV=production` the process **refuses to boot** if any dev-default secret remains (session secret, S3 creds, DB creds), if the registry JWT keypair is missing, or if private upstreams are enabled.
## Management API
A versioned REST API under **`/api/v1`** (Bearer-token auth), self-described at `GET /api/v1/docs` with an OpenAPI document at `GET /api/v1/openapi.json`. Endpoints cover:
- **Identity & orgs** — `GET /me`, `GET /orgs`, `GET /orgs/:orgId`
- **Repositories** — list / create / detail; add proxy **upstreams** and virtual-repo **members** (admin)
- **Content** — packages, versions (+ assets), artifacts, assets
- **Findings** — `GET /artifacts/:artifactId/findings` (vuln / license / secret / malware, filter by severity)
- **Tokens** — list / create / rotate / revoke (secret returned once)
- **Governance** — upsert scan policies, get/set storage & artifact quotas, apply retention
**Governance enforcement.** Per-org **quotas** (storage bytes + artifact counts; null = unlimited) are checked under a `FOR UPDATE` row lock on the publish path. **Retention** soft-deletes versions beyond `keepLastN` per package, reclaims blob refs and CAS bytes, and decrements quota usage — all transactionally.
Liveness/readiness live outside the versioned namespace: `GET /healthz` (liveness) and `GET /readyz` (verifies DB connectivity; `503` when not ready).
## Web UI
An Axum + Askama server-rendered UI for self-service management: sign-in, registration, password reset, OIDC, organization switching, dashboard overview, repository creation and configuration, package/version/artifact browsing, token management, users, groups, permissions, quotas, and retention. CSS and theme JavaScript are embedded in the Rust binary; the API serves UI and registry traffic from one process in development and production.
> Scan findings and the audit log are exposed via the **API**, not the web UI today.
## Observability
The API and both workers emit correlated JSON logs by default. Each HTTP request gets `x-request-id` / `x-correlation-id` headers, and log lines within a request or its derived queue jobs carry `request_id`, `correlation_id`, `trace_id`, and `span_id`.
Set `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318` to export logs, traces, and metrics over OTLP/HTTP (per-signal endpoint overrides exist). Traces span HTTP ingress, auth resolution, repository resolution, RBAC decisions, adapter dispatch, proxy refreshes, virtual fan-out, queue/worker lifecycle, email delivery, and every scan phase from artifact load to policy decision. Metrics cover HTTP, registry dispatch, and worker queue counts/durations/active-job gauges. Default service names (`hootifactory-api` / `-scan-worker` / `-mail-worker`) are overridable via `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES`.
## Configuration
All configuration is environment-driven and validated at startup — see [`.env.example`](.env.example) for the full annotated reference. Highlights:
| Area | Key variables |
|---|---|
| API | `API_PORT`, `API_HOST`, `REGISTRY_PUBLIC_URL`, `APP_PUBLIC_URL` |
| Database | `DATABASE_URL`, `DATABASE_POOL_*` |
| Storage | `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` |
| Auth | `SESSION_SECRET`, `REGISTRY_JWT_PRIVATE_KEY`/`_PUBLIC_KEY`, `AUTH_ALLOW_REGISTRATION`, `AUTH_OIDC_*` |
| Plugins | `REGISTRY_PLUGINS`, `SCANNERS` (allowlists; unset = all built-ins) |
| Scanning | `SCANNER_ENABLED`, `SCANNER_CLI_RUNTIME`, `SCANNER_OSV`, `SCAN_MAX_BYTES`, `*_IMAGE` |
| Email | `EMAIL_ENABLED`, `EMAIL_SMTP_*`, `EMAIL_FROM` |
`AUTH_ALLOW_REGISTRATION` / `AUTH_ALLOW_ORG_CREATION` default **on** in dev/test and **off** in production unless set explicitly.
## Deployment
Hootifactory ships as a **single multi-stage Docker image**. The same image runs the API (default), the scan worker, the mail worker, or the one-shot migration runner — selected by overriding the container command. The API serves the management UI, runs as a non-root user, and exposes a `/readyz` healthcheck.
For production, provide real secrets — `SESSION_SECRET`, the `REGISTRY_JWT_*` keypair, S3 credentials, and a non-default `DATABASE_URL`. The config guard fails fast on dev defaults, so **do not** deploy the compose demo. Bootstrap the first org with `SEED_USER` / `SEED_PASS` set explicitly; production seed runs never print passwords or token secrets.
> Keep database dumps outside the repo tree (or encrypt them) — they can contain token, session, and password hashes even when no raw secrets are present.
## Testing & CI
A three-tier pyramid:
```bash
cargo test --workspace # unit tests across every crate; DB-gated integration suites
# activate when DATABASE_URL points at a live Postgres
cargo fmt --all --check # formatting gate
cargo clippy --workspace --all-targets -- -D warnings # lint gate
Docker is the integration boundary for external CLIs — the optional scanner engines (Grype/Trivy/ClamAV) run pinned Docker images, and service-backed integration tests live in crates/<crate>/tests/, enabled by the presence of DATABASE_URL.
CI gate (.github/workflows/ci.yml) — a single aggregating PR gate status check fans out to five jobs: commit-lint (Conventional Commits on every commit + the PR title), fmt (cargo fmt --all --check), clippy (cargo clippy --workspace --all-targets -- -D warnings), test (cargo test --workspace, with a Postgres service container enabling the DB-gated integration suites), and architecture (cargo run -p xtask -- boundaries).
- Runtime: Rust stable ≥ 1.95 (Axum, sqlx, tokio).
- Commits: Conventional Commits (the squash subject = the PR title; both are linted).
- Before pushing:
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo run -p xtask -- boundaries,cargo test --workspace. - Adding a format or scanner: add a
registriesformat module or ascannersengine implementing its SDK trait (RegistryPlugin/ScannerPlugin), and add one entry to the static runtime manifest — the boundary check enforces the rest.
This repository does not currently ship a LICENSE file (the package is marked private). Until one is added, no license is granted for reuse or distribution — add a LICENSE before publishing or open-sourcing.