ADR-0018: Entitled downloads + software supply chain (release catalog, signed URLs, checksums, SBOM)
Status: Accepted · 2026-07-15Context
ADR-0017 built the Account portal through the evaluation → activate path. The next step in the customer journey (web design §7) is download: the customer, having an entitlement, obtains the installable software. The design requires that Account be the authoritative entitlement/release-access decision point, that download URLs be short-lived and issued only after an entitlement check, and that release artifacts carry checksums, signatures/provenance, and an SBOM.Decisions
1. A release catalog in the vendor registry, managed by Control admin
cp_releases (version PK, channel, artifact name, sha256, size, min_edition,
yanked, notes URL) lives in the shared vendor DB. Releases are registered,
listed, and yanked through Bearer-admin Control endpoints
(/control/v1/admin/releases[...]), so the same operator credential that manages
accounts manages the catalog. Registration validates the sha256 is hex — the
checksum is a first-class field, not an afterthought.
2. Account is the authoritative entitlement decision point; URLs are short-lived + signed
The portal mints a download URL only after three checks pass for the requesting user: membership in the org, the account is active, and the account’s edition satisfies the release’smin_edition. The URL carries a stateless,
HMAC-signed token (b64url(claims).b64url(mac), claims = {version, accountId, exp}) — no session is needed at download time, and expiry (default 15 min) is
the control. The download endpoint re-checks the signature and expiry, and
re-reads the release so a yank takes effect immediately even for
already-minted URLs. Every mint and every served download is a control event.
3. The download endpoint never trusts a path
The served file isjoin(downloadsDir, basename(release.artifactName)) — the
admin-registered artifact name is reduced to its basename, so a catalog entry can
never point the endpoint outside the downloads directory (path-traversal safe).
A missing artifact is an honest 404 (artifact_unavailable), not a 500. The
response sets x-checksum-sha256 so the client can verify what it received.
4. Feature-gated, with a production secret guard
Downloads are off unlessFMD_DOWNLOADS_DIR is set (endpoints return 503). The
URL-signing secret resolves from FMD_DOWNLOADS_URL_SECRET_REF; production
refuses to enable downloads without an explicit secret (an ephemeral per-process
secret would break multi-replica mint/verify and not survive restarts).
Development falls back to an ephemeral secret so the feature is runnable locally.
5. Supply chain: checksums + SBOM on every release
The release workflow now generates a CycloneDX 1.5 SBOM (scripts/sbom.ts,
walking the full Bun-isolated install tree incl. scoped/nested packages) and a
SHA256SUMS over every attached artifact, and publishes both with the GitHub
Release alongside the web-dist tarball. Combined with the immutable version tag
and the GHCR image, a customer can verify provenance and integrity end to end.
Consequences
- The evaluation → download → activate loop is now runnable: a licensed account lists its edition-permitted releases and gets a short-lived, verifiable URL.
- The catalog stores metadata + checksum only; artifact bytes live on the configured origin (a directory here; object storage in a real deployment — the contract is the same).
- Out of scope (future): object-storage/CDN origin with its own signed URLs,
Sigstore/cosign artifact signatures + attestations, delta/updates channel
(
updates.findmydata.io), and per-download rate limiting.
Verification
account/downloads.test.ts (14 tests) + additions to vendor admin coverage —
token mint/verify roundtrip, tamper/expiry/forgery rejection, edition-gate matrix,
membership + account-active guards, CSRF on mint, yank-takes-effect-immediately,
a real signed-URL download whose bytes match the published sha256, path-safety
(basename), missing-artifact 404, and the admin register→list→yank lifecycle
(Bearer-gated, hex-sha256 validated). All 263 server tests pass; typecheck + lint
clean; the SBOM generator produces valid CycloneDX for the installed tree.