feat(desktop): downloads from a configured public GitHub releases repo
Why
The dashboard's Desktop downloads card (now on Settings > Workspace) reads only the signed Desktop feed. That feed needs AWS KMS and S3 in production, so a local Kind stack shows "There are no Desktop downloads". The Kind Desktop is published to a public GitHub releases repo and updates from it, so the card can show that instead.
What
SELFHOST_DESKTOP_RELEASES_REPO=<owner>/<repo> (strict ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$, unset by default) turns on GET /api/v1/desktop/releases for any signed-in member. It returns { source: { kind: "github", repo, releasesUrl }, release }, where release is the newest non-draft release (pre-releases included) in the card's manifest shape. Installer names Desktop-<profile>-<version>-<OS>-<arch>[-probe|-Setup].<ext> map to platform, arch, kind and the macOS os_floor; anything else is skipped.
Bounds
- Control calls only
https://api.github.com/repos/<owner>/<repo>/releases: the release list, and each.sha256sidecar through its API asset URL. That URL's single redirect may only go to*.githubusercontent.com. - No token. The list is capped at 2 MiB, a sidecar at 1 KiB, and the whole read at 8 s.
- Results are cached for 5 minutes, failures included. Any upstream failure returns
release: null, never a 5xx.
Integrity
- An asset is served only with a matching sidecar from the same release (digest, plus the file name when present).
- If GitHub reports its own
sha256:digest for the asset, it must agree with the sidecar. - This gives integrity, not publisher authenticity. The webapp labels the source as GitHub releases, not the signed Desktop feed, and uses it only when the signed feed has no profiles.
URLs: url is kept only if it's https://github.com or https://objects.githubusercontent.com under /<owner>/<repo>/releases/download/.
Unset: 404, so nothing changes for any other install. Kind sets it in its own overlay.
Tests
test/desktop-releases.test.ts:- the repo setting's format check;
- name mapping for every real Kind asset name, with non-installers skipped;
- the URL allow-list (other hosts, repos and paths, ports, credentials and query strings are refused);
- mapping with sidecar checksums: newest non-draft release, pre-release kept, an asset without a sidecar dropped;
- the 5-minute cache;
- only api.github.com and githubusercontent hosts are called;
- assets dropped for: digest mismatch, sidecar naming a different file, foreign download URL, redirect to another host (which is never fetched);
- upstream 503, a thrown fetch and an empty list all give
release: null, with the failure cached for 5 minutes.
test/sessions.test.ts: 404 when unset, 401 without a token, 200 for a member withno-store, and exactly one GitHub call.
npm test passes locally (1226). Companion webapp PR: relay-webapp branch feat/downloads-github-releases.
๐ค Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith ยท Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Adds GET /api/v1/desktop/releases so the downloads card can show the latest release from a public GitHub repo when the signed Desktop feed has nothing, such as on a local Kind stack. It's gated by a new SELFHOST_DESKTOP_RELEASES_REPO setting, unset by default, in which case the route answers 404 and nothing changes.
Security and integrity bounds
- Calls only
api.github.com(release list plus each.sha256sidecar's asset URL), with one redirect allowed only to*.githubusercontent.com; no token, size caps, 8-second deadline, and a 5-minute cache that includes failures. - An asset is served only with a matching sidecar from the same release whose digest agrees with GitHub's own when reported; download URLs are kept only on
github.comandobjects.githubusercontent.comunder/<owner>/<repo>/releases/download/, with the repo compared case-insensitively. - Refreshes stay inside GitHub's unauthenticated quota: the list is a conditional request (a 304 costs nothing) and verified sidecar digests are cached per asset, so unchanged assets are never re-read.
- Any upstream failure serves the last complete release, falling back to
release: nullonly when none exists โ never a 5xx โ and the dashboard labels the source as GitHub releases rather than the signed feed.