Skip to main content

ADR-0018: Entitled downloads + software supply chain (release catalog, signed URLs, checksums, SBOM)

Status: Accepted · 2026-07-15

Context

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’s min_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 is join(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 unless FMD_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.