diff --git a/.changeset/calm-updates-cache.md b/.changeset/calm-updates-cache.md new file mode 100644 index 000000000..f023715d7 --- /dev/null +++ b/.changeset/calm-updates-cache.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Route curl-install release checks through globally refreshed metadata by default while retaining direct GitHub fallback and analytics opt-outs. diff --git a/README.md b/README.md index 05b306b5c..f89abd0d7 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built ## Install -The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery stays direct to GitHub while Hunk's anonymous aggregate endpoint is evaluated; set `HUNK_ENABLE_RELEASE_PROXY=1` to opt into testing it: +The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery uses Hunk's anonymous aggregate endpoint with direct GitHub fallback: ```bash curl -fsSL https://hunk.dev/install.sh | sh diff --git a/install.sh b/install.sh index 3fd04feed..840d74ced 100755 --- a/install.sh +++ b/install.sh @@ -18,8 +18,6 @@ # HUNK_NO_MODIFY_PATH set to 1 to leave shell startup files alone # HUNK_ALLOW_CONFLICTING_INSTALLS # set to 1 to install alongside another Hunk -# HUNK_ENABLE_RELEASE_PROXY -# set to 1 to test Hunk's aggregate release endpoint # HUNK_DISABLE_ANALYTICS # set to 1 to resolve releases directly from GitHub # DO_NOT_TRACK set to 1 to resolve releases directly from GitHub @@ -77,8 +75,6 @@ Environment: HUNK_NO_MODIFY_PATH set to 1 for --no-modify-path HUNK_ALLOW_CONFLICTING_INSTALLS set to 1 for --force - HUNK_ENABLE_RELEASE_PROXY - set to 1 to test Hunk's aggregate release endpoint HUNK_DISABLE_ANALYTICS set to 1 to bypass Hunk's aggregate release endpoint DO_NOT_TRACK set to 1 to bypass Hunk's aggregate release endpoint @@ -438,7 +434,7 @@ main() { release_current="$(installed_version "${HOME}/.hunk/bin/hunk")" fi # Parsed with sed rather than jq so the installer needs nothing but a shell and a downloader. - if [ "${HUNK_ENABLE_RELEASE_PROXY:-0}" = "1" ] && [ "${HUNK_DISABLE_ANALYTICS:-0}" != "1" ] && [ "${DO_NOT_TRACK:-0}" != "1" ]; then + if [ "${HUNK_DISABLE_ANALYTICS:-0}" != "1" ] && [ "${DO_NOT_TRACK:-0}" != "1" ]; then proxy_payload="$(fetch_release_proxy "$release_current" 2>/dev/null)" || proxy_payload="" version="$(printf '%s\n' "$proxy_payload" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" printf '%s\n' "$version" | grep -q '^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' || version="" diff --git a/packages/hunk/README.md b/packages/hunk/README.md index 05b306b5c..f89abd0d7 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built ## Install -The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery stays direct to GitHub while Hunk's anonymous aggregate endpoint is evaluated; set `HUNK_ENABLE_RELEASE_PROXY=1` to opt into testing it: +The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery uses Hunk's anonymous aggregate endpoint with direct GitHub fallback: ```bash curl -fsSL https://hunk.dev/install.sh | sh diff --git a/packages/hunk/src/core/install/latestRelease.test.ts b/packages/hunk/src/core/install/latestRelease.test.ts index af6e2b850..9d00ea88c 100644 --- a/packages/hunk/src/core/install/latestRelease.test.ts +++ b/packages/hunk/src/core/install/latestRelease.test.ts @@ -38,13 +38,13 @@ describe("release channel lookups", () => { expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]); }); - test("reads curl release metadata through the first-party endpoint", async () => { + test("reads curl release metadata through the first-party endpoint by default", async () => { const requested: string[] = []; const headers: Headers[] = []; await expect( fetchChannelVersions("curl", { - env: { HUNK_ENABLE_RELEASE_PROXY: "1" }, + env: {}, requestSource: "startup", currentVersion: "1.3.0", fetchImpl: async (input, init) => { @@ -65,7 +65,7 @@ describe("release channel lookups", () => { const accepts: Array = []; await expect( fetchChannelVersions("curl", { - env: { HUNK_ENABLE_RELEASE_PROXY: "1" }, + env: {}, fetchImpl: async (input, init) => { requested.push(String(input)); accepts.push(new Headers(init?.headers).get("accept")); @@ -83,25 +83,8 @@ describe("release channel lookups", () => { } }); - test("uses GitHub directly unless first-party release testing is enabled", async () => { - const requested: string[] = []; - await expect( - fetchChannelVersions("curl", { - env: {}, - fetchImpl: async (input) => { - requested.push(String(input)); - return jsonResponse({ tag_name: "v1.4.0" }); - }, - }), - ).resolves.toEqual({ latest: "1.4.0" }); - expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]); - }); - test("bypasses first-party analytics when either opt-out is set", async () => { - for (const env of [ - { HUNK_ENABLE_RELEASE_PROXY: "1", HUNK_DISABLE_ANALYTICS: "1" }, - { HUNK_ENABLE_RELEASE_PROXY: "1", DO_NOT_TRACK: "1" }, - ]) { + for (const env of [{ HUNK_DISABLE_ANALYTICS: "1" }, { DO_NOT_TRACK: "1" }]) { const requested: string[] = []; await expect( fetchChannelVersions("curl", { @@ -119,7 +102,7 @@ describe("release channel lookups", () => { test("drops curl release metadata that is not a stable version", async () => { await expect( fetchChannelVersions("curl", { - env: { HUNK_ENABLE_RELEASE_PROXY: "1" }, + env: {}, fetchImpl: async (input) => String(input).includes("updates.hunk.dev") ? jsonResponse({ version: "1.4.0-beta.1" }) diff --git a/packages/hunk/src/core/install/latestRelease.ts b/packages/hunk/src/core/install/latestRelease.ts index 4e6b5e480..d68ffeea6 100644 --- a/packages/hunk/src/core/install/latestRelease.ts +++ b/packages/hunk/src/core/install/latestRelease.ts @@ -16,7 +16,6 @@ const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json"; const HUNK_CURL_RELEASE_URL = "https://updates.hunk.dev/v1/curl/latest"; const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest"; const DEFAULT_RELEASE_FETCH_TIMEOUT_MS = 5_000; -const ENABLE_RELEASE_PROXY_ENV = "HUNK_ENABLE_RELEASE_PROXY"; const DISABLE_ANALYTICS_ENV = "HUNK_DISABLE_ANALYTICS"; const DO_NOT_TRACK_ENV = "DO_NOT_TRACK"; @@ -125,13 +124,9 @@ export async function fetchHomebrewChannelVersions( return { latest: stable && isStableVersion(stable) ? stable : undefined }; } -/** Return whether this process explicitly opts into the first-party release proxy. */ +/** Return whether this process permits the first-party release proxy. */ function releaseProxyEnabled(env: NodeJS.ProcessEnv | undefined) { - return ( - env?.[ENABLE_RELEASE_PROXY_ENV] === "1" && - env[DISABLE_ANALYTICS_ENV] !== "1" && - env[DO_NOT_TRACK_ENV] !== "1" - ); + return env?.[DISABLE_ANALYTICS_ENV] !== "1" && env?.[DO_NOT_TRACK_ENV] !== "1"; } /** Build bounded headers for the first-party curl release endpoint. */ @@ -149,9 +144,9 @@ function curlReleaseHeaders(deps: ReleaseLookupDeps) { /** * Fetch the stable release published for curl installs. * - * The opt-in first-party endpoint supplies aggregate release-check observability and normalized - * metadata while it is evaluated before general rollout. Every other client and every endpoint - * failure uses GitHub directly so the proxy can never make update discovery less reliable. + * The first-party endpoint supplies aggregate release-check observability and normalized metadata. + * Analytics opt-outs and every endpoint failure use GitHub directly so the proxy cannot make update + * discovery less reliable. */ export async function fetchCurlChannelVersions( deps: ReleaseLookupDeps = {}, diff --git a/packages/hunk/src/core/install/selfUpdate.test.ts b/packages/hunk/src/core/install/selfUpdate.test.ts index 34685c3d3..c02b319c2 100644 --- a/packages/hunk/src/core/install/selfUpdate.test.ts +++ b/packages/hunk/src/core/install/selfUpdate.test.ts @@ -207,7 +207,6 @@ describe("hunk update", () => { env: { PATH: "/usr/bin", HOME: "/home/reviewer", - HUNK_ENABLE_RELEASE_PROXY: "1", }, }); @@ -219,7 +218,6 @@ describe("hunk update", () => { { PATH: "/usr/bin", HOME: "/home/reviewer", - HUNK_ENABLE_RELEASE_PROXY: "1", HUNK_VERSION: "1.1.0", }, ]); @@ -244,7 +242,7 @@ describe("hunk update", () => { const result = await runUpdate({ installSource: "curl", input: { check: true }, - env: { HUNK_ENABLE_RELEASE_PROXY: "1" }, + env: {}, }); expect(result.exitCode).toBe(0); diff --git a/packages/hunk/src/core/process/updateNotice.test.ts b/packages/hunk/src/core/process/updateNotice.test.ts index 51d6ba02f..be02fbf06 100644 --- a/packages/hunk/src/core/process/updateNotice.test.ts +++ b/packages/hunk/src/core/process/updateNotice.test.ts @@ -126,14 +126,14 @@ describe("startup update notice", () => { }); }); - test("reads the GitHub releases API for curl installer installs", async () => { + test("reads the first-party release endpoint for curl installer installs", async () => { await withTempStatePath(async (statePath) => { const requested: string[] = []; const headers: Headers[] = []; await expect( resolveStartupUpdateNotice({ - env: { HUNK_ENABLE_RELEASE_PROXY: "1" }, + env: {}, fetchImpl: async (input, init) => { requested.push(String(input)); headers.push(new Headers(init?.headers)); diff --git a/scripts/packaging/install-sh.test.ts b/scripts/packaging/install-sh.test.ts index 3076e9c66..ebe2de2d7 100644 --- a/scripts/packaging/install-sh.test.ts +++ b/scripts/packaging/install-sh.test.ts @@ -130,7 +130,7 @@ function runConflictCheck( /** Run default-version resolution against a stub downloader and an already-current target. */ function runReleaseResolution( - options: { enableProxy?: boolean; proxyFails?: boolean; disableAnalytics?: boolean } = {}, + options: { proxyFails?: boolean; disableAnalytics?: boolean; doNotTrack?: boolean } = {}, ) { const root = mkdtempSync(join(tmpdir(), "hunk-install-release-")); const home = join(root, "home"); @@ -165,8 +165,8 @@ function runReleaseResolution( PATH: [toolsDir, targetDir, "/usr/bin", "/bin"].join(":"), CURL_LOG: curlLog, PROXY_FAILS: options.proxyFails ? "1" : "0", - HUNK_ENABLE_RELEASE_PROXY: options.enableProxy ? "1" : undefined, HUNK_DISABLE_ANALYTICS: options.disableAnalytics ? "1" : undefined, + DO_NOT_TRACK: options.doNotTrack ? "1" : undefined, }, stdin: "ignore", stdout: "pipe", @@ -251,12 +251,12 @@ describe("hunk.dev install script", () => { test.skipIf(process.platform === "win32")( "resolves through Hunk and falls back directly to GitHub", () => { - const proxied = runReleaseResolution({ enableProxy: true }); + const proxied = runReleaseResolution(); expect(proxied.exitCode).toBe(0); expect(proxied.requests).toEqual(["https://updates.hunk.dev/v1/curl/latest"]); expect(proxied.stdout).toContain("hunk 1.2.3 is already installed."); - const fallback = runReleaseResolution({ enableProxy: true, proxyFails: true }); + const fallback = runReleaseResolution({ proxyFails: true }); expect(fallback.exitCode).toBe(0); expect(fallback.requests).toEqual([ "https://updates.hunk.dev/v1/curl/latest", @@ -265,25 +265,16 @@ describe("hunk.dev install script", () => { }, ); - test.skipIf(process.platform === "win32")( - "uses GitHub directly unless proxy testing is enabled", - () => { - const result = runReleaseResolution(); - expect(result.exitCode).toBe(0); - expect(result.requests).toEqual([ - "https://api.github.com/repos/modem-dev/hunk/releases/latest", - ]); - }, - ); - test.skipIf(process.platform === "win32")( "bypasses Hunk release analytics when opted out", () => { - const result = runReleaseResolution({ enableProxy: true, disableAnalytics: true }); - expect(result.exitCode).toBe(0); - expect(result.requests).toEqual([ - "https://api.github.com/repos/modem-dev/hunk/releases/latest", - ]); + for (const options of [{ disableAnalytics: true }, { doNotTrack: true }]) { + const result = runReleaseResolution(options); + expect(result.exitCode).toBe(0); + expect(result.requests).toEqual([ + "https://api.github.com/repos/modem-dev/hunk/releases/latest", + ]); + } }, ); diff --git a/website/src/content/docs/docs/start/install.md b/website/src/content/docs/docs/start/install.md index f39e200aa..a474c2a78 100644 --- a/website/src/content/docs/docs/start/install.md +++ b/website/src/content/docs/docs/start/install.md @@ -24,8 +24,7 @@ The script accepts these settings: | `HUNK_INSTALL_DIR` | Install the binary into this directory instead of `~/.hunk/bin`. | | `--no-modify-path` (or `HUNK_NO_MODIFY_PATH=1`) | Leave shell startup files alone. | | `--force` (or `HUNK_ALLOW_CONFLICTING_INSTALLS=1`) | Install despite another Hunk on PATH or in a known version-manager directory. | -| `HUNK_ENABLE_RELEASE_PROXY=1` | Test Hunk's anonymous aggregate release endpoint instead of resolving directly from GitHub. | -| `HUNK_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` | Keep release discovery direct to GitHub even when proxy testing is enabled. | +| `HUNK_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` | Keep release discovery direct to GitHub instead of using Hunk's aggregate release endpoint. | By default, the installer refuses to create a second Hunk installation. It lists every competing path it finds, its version and PATH precedence, and the command that removes it. Remove those @@ -40,7 +39,7 @@ curl -fsSL https://hunk.dev/install.sh | HUNK_VERSION=0.19.0 sh On Hunk 0.20 and newer, `hunk update` refreshes a default install in place. An install redirected with `HUNK_INSTALL_DIR` cannot be auto-detected later (the variable is gone once your shell exits), so update one of those by re-running the script with the same `HUNK_INSTALL_DIR`; the installer prints a reminder at the end of a custom-directory install. -Release discovery remains direct to GitHub by default while Hunk's cached endpoint is evaluated. Set `HUNK_ENABLE_RELEASE_PROXY=1` to route default install-script release resolution, automatic startup update checks, `hunk update --check`, and `hunk update` through it for curl-managed installs. Automatic checks normally run at most once every four hours for each user profile; simultaneous processes can produce an occasional duplicate, while explicit update commands and installer runs remain immediate. The endpoint records aggregate request source and current-version fields, but Hunk sends no installation ID, repository, hostname, cookie, or request body. `HUNK_DISABLE_ANALYTICS=1` and `DO_NOT_TRACK=1` override the testing flag, and every endpoint failure falls back directly to GitHub. +Release discovery for curl-managed installs routes default install-script resolution, automatic startup update checks, `hunk update --check`, and `hunk update` through Hunk's release endpoint. Automatic checks normally run at most once every four hours for each user profile; simultaneous processes can produce an occasional duplicate, while explicit update commands and installer runs remain immediate. The endpoint records aggregate request source and current-version fields, but Hunk sends no installation ID, repository, hostname, cookie, or request body. `HUNK_DISABLE_ANALYTICS=1` and `DO_NOT_TRACK=1` keep release discovery direct to GitHub, and every endpoint failure falls back directly to GitHub. Windows is not covered by the script; use npm or mise there. diff --git a/workers/release-proxy/README.md b/workers/release-proxy/README.md index 0e722c1e7..9b40b8d37 100644 --- a/workers/release-proxy/README.md +++ b/workers/release-proxy/README.md @@ -1,18 +1,23 @@ # Hunk release proxy -This stateless Cloudflare Worker serves `GET /v1/curl/latest`. It caches and normalizes GitHub's -latest stable Hunk release to: +This Cloudflare Worker serves `GET /v1/curl/latest`. It normalizes GitHub's latest stable Hunk +release to: ```json { "version": "0.20.1" } ``` +A cron trigger refreshes that version in Workers KV every minute. Requests read KV instead of +GitHub, so release-check traffic cannot consume GitHub's API quota. Failed refreshes preserve the +last valid version, while metadata older than six hours returns an endpoint error so Hunk can use +its direct-GitHub fallback. Unchanged metadata writes a fresh heartbeat at most once per hour. + The Worker writes one structured `release_check` log containing only allowlisted `source` and -`currentVersion` values. Client responses use `Cache-Control: no-store` so Cloudflare's outer cache -cannot bypass the Worker and its per-request log; the Worker Cache API still keeps the normalized -GitHub response for five minutes. It does not use D1, cookies, request bodies, or installation -identifiers. Cloudflare's infrastructure may provide its own request metadata subject to the -account's log and retention configuration. +`currentVersion` values. Scheduled attempts write a bounded `release_refresh` result. Client +responses use `Cache-Control: no-store` so Cloudflare's outer cache cannot bypass the Worker and its +per-request log. It does not use cookies, request bodies, or installation identifiers. Cloudflare's +infrastructure may provide its own request metadata subject to the account's log and retention +configuration. ## Development @@ -23,12 +28,21 @@ npm run typecheck npm run dev ``` -`wrangler deploy` publishes the Worker to the configured `updates.hunk.dev` custom domain. The -`release-proxy.yml` workflow checks pull requests and `main` without receiving production -credentials; deployment stays a manual operation from a trusted maintainer machine: +## Deployment + +The committed `RELEASE_METADATA` binding points at the production KV namespace. Before the first +deployment, add a read-only GitHub credential as a Worker-scoped secret: + +```sh +npx wrangler secret put GITHUB_TOKEN +``` + +`wrangler deploy` publishes the Worker and its every-minute cron trigger to the configured +`updates.hunk.dev` custom domain. The `release-proxy.yml` workflow checks pull requests and `main` +without receiving production credentials; deployment stays a manual operation from a trusted +maintainer machine: ```sh -npx wrangler login npm ci npm test npm run typecheck @@ -37,6 +51,5 @@ curl -fsS https://updates.hunk.dev/v1/curl/latest ``` The client and installer fall back directly to GitHub, so their rollout does not depend on -deployment ordering. Verify the endpoint and its bounded structured logs after each deployment. No -GitHub token is required for the initial anonymous upstream request; if one is added later, store it -as a Worker secret and never in Hunk. +deployment ordering. Verify the endpoint and its bounded structured logs after each deployment. +Keep the GitHub credential in the Worker secret, never in Hunk or Wrangler configuration. diff --git a/workers/release-proxy/src/index.test.ts b/workers/release-proxy/src/index.test.ts index a8e1a2d82..4e54f87aa 100644 --- a/workers/release-proxy/src/index.test.ts +++ b/workers/release-proxy/src/index.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { createReleaseProxyHandler } from "./index"; +import { createReleaseProxyHandler, createReleaseProxyScheduledHandler } from "./index"; -/** Build a direct invocation context and retain deferred cache work for assertions. */ +const NOW = 2_000_000_000_000; +const RELEASE_METADATA_KEY = "latest-stable-release"; + +/** Build a direct invocation context and retain deferred work for assertions. */ function createTestContext() { const pending: Promise[] = []; return { @@ -10,130 +13,254 @@ function createTestContext() { }; } -/** Build a tiny in-memory implementation of the Worker cache seam. */ -function createTestCache() { - const entries = new Map(); +/** Encode one value as the Worker's validated global metadata record. */ +function metadata(version: string, checkedAt = NOW) { + return JSON.stringify({ version, checkedAt }); +} + +/** Build a tiny in-memory implementation of the Worker KV binding. */ +function createTestReleaseMetadata(initialValue?: string) { + const entries = new Map(); + const writes: Array<{ key: string; value: string }> = []; + if (initialValue) entries.set(RELEASE_METADATA_KEY, initialValue); return { entries, - cache: { - match: async (request: Request) => entries.get(request.url)?.clone(), - put: async (request: Request, response: Response) => { - entries.set(request.url, response.clone()); + writes, + namespace: { + get: async (key: string) => entries.get(key) ?? null, + put: async (key: string, value: string) => { + writes.push({ key, value }); + entries.set(key, value); }, }, }; } +/** Build the production route request used throughout the Worker tests. */ +function releaseRequest(init?: RequestInit) { + return new Request("https://updates.hunk.dev/v1/curl/latest", init); +} + +/** Build the shape Cloudflare supplies to a scheduled Worker invocation. */ +function scheduledController() { + return { scheduledTime: NOW, cron: "* * * * *" }; +} + describe("release proxy Worker", () => { - test("normalizes and caches GitHub's latest stable release", async () => { - const upstreamRequests: Array<{ url: string; headers: Headers }> = []; - const { cache, entries } = createTestCache(); - const { context, settle } = createTestContext(); + test("serves fresh global metadata without requesting GitHub", async () => { + const { namespace } = createTestReleaseMetadata(metadata("1.2.3")); + const { context } = createTestContext(); const handler = createReleaseProxyHandler({ - cache, - fetchImpl: async (input, init) => { - upstreamRequests.push({ url: String(input), headers: new Headers(init?.headers) }); - return Response.json({ tag_name: "v1.2.3" }); + fetchImpl: async () => { + throw new Error("requests must not reach GitHub"); }, log: () => {}, + now: () => NOW, }); - const response = await handler( - new Request("https://updates.hunk.dev/v1/curl/latest"), - {}, - context, - ); - await settle(); + const response = await handler(releaseRequest(), { RELEASE_METADATA: namespace }, context); expect(response.status).toBe(200); expect(await response.json()).toEqual({ version: "1.2.3" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - expect(upstreamRequests).toHaveLength(1); - expect(upstreamRequests[0]?.url).toBe( - "https://api.github.com/repos/modem-dev/hunk/releases/latest", - ); - expect(upstreamRequests[0]?.headers.get("accept")).toBe("application/vnd.github+json"); - expect(upstreamRequests[0]?.headers.get("user-agent")).toBe("hunk-release-proxy"); - expect( - entries.get("https://updates.hunk.dev/v1/curl/latest")?.headers.get("cache-control"), - ).toBe("public, max-age=300"); - - const second = await handler( - new Request("https://updates.hunk.dev/v1/curl/latest"), - {}, - context, - ); - expect(await second.json()).toEqual({ version: "1.2.3" }); - expect(second.headers.get("cache-control")).toBe("no-store"); - expect(upstreamRequests).toHaveLength(1); }); - test("rejects prereleases and malformed GitHub payloads", async () => { - for (const payload of [{ tag_name: "v1.2.3-beta.1" }, { name: "v1.2.3" }, null]) { + test("returns an error for empty, invalid, future, or stale metadata without requesting GitHub", async () => { + const values = [ + undefined, + "not json", + metadata("1.2.3", NOW + 1), + metadata("1.2.3", NOW - 6 * 60 * 60 * 1_000 - 1), + ]; + for (const value of values) { + const { namespace } = createTestReleaseMetadata(value); const { context } = createTestContext(); + let upstreamRequests = 0; const handler = createReleaseProxyHandler({ - fetchImpl: async () => Response.json(payload), + fetchImpl: async () => { + upstreamRequests += 1; + return Response.json({ tag_name: "v1.2.4" }); + }, log: () => {}, + now: () => NOW, }); - const response = await handler( - new Request("https://updates.hunk.dev/v1/curl/latest"), - {}, - context, - ); - expect(response.status).toBe(502); - expect(await response.json()).toEqual({ error: "invalid_upstream_response" }); + + const response = await handler(releaseRequest(), { RELEASE_METADATA: namespace }, context); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "metadata_unavailable" }); + expect(upstreamRequests).toBe(0); } }); - test("contains upstream failures and unknown routes", async () => { + test("contains KV read failures", async () => { const { context } = createTestContext(); - const handler = createReleaseProxyHandler({ - fetchImpl: async () => { - throw new Error("offline"); + const handler = createReleaseProxyHandler({ log: () => {}, now: () => NOW }); + const response = await handler( + releaseRequest(), + { + RELEASE_METADATA: { + get: async () => { + throw new Error("KV unavailable"); + }, + put: async () => {}, + }, }, - log: () => {}, + context, + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: "storage_unavailable" }); + }); + + test("refreshes global metadata from GitHub with the Worker secret", async () => { + const upstreamRequests: Array<{ url: string; headers: Headers }> = []; + const logs: string[] = []; + const { namespace, entries } = createTestReleaseMetadata(metadata("1.2.2", NOW - 3_600_000)); + const { context } = createTestContext(); + const scheduled = createReleaseProxyScheduledHandler({ + fetchImpl: async (input, init) => { + upstreamRequests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ tag_name: "v1.2.3" }); + }, + log: (entry) => logs.push(entry), + now: () => NOW, }); - const failed = await handler( - new Request("https://updates.hunk.dev/v1/curl/latest"), - {}, + await scheduled( + scheduledController(), + { RELEASE_METADATA: namespace, GITHUB_TOKEN: "worker-secret" }, context, ); - expect(failed.status).toBe(502); - expect(failed.headers.get("cache-control")).toBe("no-store"); - expect(await failed.json()).toEqual({ error: "upstream_unavailable" }); - const missing = await handler(new Request("https://updates.hunk.dev/other"), {}, context); - expect(missing.status).toBe(404); - expect(missing.headers.get("cache-control")).toBe("no-store"); + expect(JSON.parse(entries.get(RELEASE_METADATA_KEY) ?? "null")).toEqual({ + version: "1.2.3", + checkedAt: NOW, + }); + expect(upstreamRequests[0]?.url).toBe( + "https://api.github.com/repos/modem-dev/hunk/releases/latest", + ); + expect(upstreamRequests[0]?.headers.get("accept")).toBe("application/vnd.github+json"); + expect(upstreamRequests[0]?.headers.get("authorization")).toBe("Bearer worker-secret"); + expect(upstreamRequests[0]?.headers.get("user-agent")).toBe("hunk-release-proxy"); + expect(upstreamRequests[0]?.headers.get("x-github-api-version")).toBe("2022-11-28"); + expect(logs.map((entry) => JSON.parse(entry))).toEqual([ + { event: "release_refresh", status: "updated", version: "1.2.3" }, + ]); }); - test("bounds a stalled GitHub lookup", async () => { + test("does not rewrite unchanged metadata more than once per hour", async () => { + const { namespace, writes } = createTestReleaseMetadata(metadata("1.2.3", NOW - 59 * 60_000)); const { context } = createTestContext(); - const handler = createReleaseProxyHandler({ - upstreamTimeoutMs: 1, - fetchImpl: async (_input, init) => - await new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); - }), + const scheduled = createReleaseProxyScheduledHandler({ + fetchImpl: async () => Response.json({ tag_name: "v1.2.3" }), log: () => {}, + now: () => NOW, }); - const response = await handler( - new Request("https://updates.hunk.dev/v1/curl/latest"), - {}, - context, + await scheduled(scheduledController(), { RELEASE_METADATA: namespace }, context); + + expect(writes).toEqual([]); + }); + + test("refreshes the metadata heartbeat after one hour", async () => { + const { namespace, entries, writes } = createTestReleaseMetadata( + metadata("1.2.3", NOW - 60 * 60_000), ); - expect(response.status).toBe(502); - expect(response.headers.get("cache-control")).toBe("no-store"); + const { context } = createTestContext(); + const scheduled = createReleaseProxyScheduledHandler({ + fetchImpl: async () => Response.json({ tag_name: "v1.2.3" }), + log: () => {}, + now: () => NOW, + }); + + await scheduled(scheduledController(), { RELEASE_METADATA: namespace }, context); + + expect(writes).toHaveLength(1); + expect(JSON.parse(entries.get(RELEASE_METADATA_KEY) ?? "null")).toEqual({ + version: "1.2.3", + checkedAt: NOW, + }); }); - test("bounds a stalled GitHub response body", async () => { + test("preserves last-known metadata when a scheduled refresh fails", async () => { + const logs: string[] = []; + const initial = metadata("1.2.3", NOW - 3_600_000); + const { namespace, entries } = createTestReleaseMetadata(initial); const { context } = createTestContext(); - const handler = createReleaseProxyHandler({ - upstreamTimeoutMs: 1, - fetchImpl: async (_input, init) => { + const scheduled = createReleaseProxyScheduledHandler({ + fetchImpl: async () => new Response("rate limited", { status: 403 }), + log: (entry) => logs.push(entry), + now: () => NOW, + }); + + await scheduled(scheduledController(), { RELEASE_METADATA: namespace }, context); + + expect(entries.get(RELEASE_METADATA_KEY)).toBe(initial); + expect(logs.map((entry) => JSON.parse(entry))).toEqual([ + { + event: "release_refresh", + status: "failed", + reason: "upstream_unavailable", + upstreamStatus: 403, + }, + ]); + }); + + test("reports KV write failures separately and preserves last-known metadata", async () => { + const initial = metadata("1.2.3", NOW - 3_600_000); + const logs: string[] = []; + const { context } = createTestContext(); + const scheduled = createReleaseProxyScheduledHandler({ + fetchImpl: async () => Response.json({ tag_name: "v1.2.4" }), + log: (entry) => logs.push(entry), + now: () => NOW, + }); + + await scheduled( + scheduledController(), + { + RELEASE_METADATA: { + get: async () => initial, + put: async () => { + throw new Error("KV unavailable"); + }, + }, + }, + context, + ); + + expect(logs.map((entry) => JSON.parse(entry))).toEqual([ + { event: "release_refresh", status: "failed", reason: "storage_unavailable" }, + ]); + }); + + test("rejects prereleases and malformed GitHub payloads", async () => { + for (const payload of [{ tag_name: "v1.2.3-beta.1" }, { name: "v1.2.3" }, null]) { + const logs: string[] = []; + const { namespace, writes } = createTestReleaseMetadata(); + const { context } = createTestContext(); + const scheduled = createReleaseProxyScheduledHandler({ + fetchImpl: async () => Response.json(payload), + log: (entry) => logs.push(entry), + now: () => NOW, + }); + + await scheduled(scheduledController(), { RELEASE_METADATA: namespace }, context); + expect(writes).toEqual([]); + expect(logs.map((entry) => JSON.parse(entry))).toEqual([ + { event: "release_refresh", status: "failed", reason: "invalid_upstream_response" }, + ]); + } + }); + + test("bounds stalled GitHub lookups and response bodies", async () => { + const fetches = [ + async (_input: RequestInfo | URL, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }), + async (_input: RequestInfo | URL, init?: RequestInit) => { const body = new ReadableStream({ start(controller) { init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted"))); @@ -141,26 +268,43 @@ describe("release proxy Worker", () => { }); return new Response(body); }, - log: () => {}, - }); + ]; - const response = await handler( - new Request("https://updates.hunk.dev/v1/curl/latest"), - {}, - context, - ); - expect(response.status).toBe(502); - expect(response.headers.get("cache-control")).toBe("no-store"); + for (const fetchImpl of fetches) { + const logs: string[] = []; + const { namespace } = createTestReleaseMetadata(); + const { context } = createTestContext(); + const scheduled = createReleaseProxyScheduledHandler({ + upstreamTimeoutMs: 1, + fetchImpl, + log: (entry) => logs.push(entry), + now: () => NOW, + }); + + await scheduled(scheduledController(), { RELEASE_METADATA: namespace }, context); + expect(logs.map((entry) => JSON.parse(entry))).toEqual([ + { event: "release_refresh", status: "failed", reason: "upstream_unavailable" }, + ]); + } }); - test("logs only allowlisted request dimensions", async () => { + test("contains unknown routes and logs only allowlisted request dimensions", async () => { const logs: string[] = []; + const { namespace } = createTestReleaseMetadata(metadata("1.2.3")); const { context } = createTestContext(); const handler = createReleaseProxyHandler({ - fetchImpl: async () => Response.json({ tag_name: "v1.2.3" }), log: (entry) => logs.push(entry), + now: () => NOW, }); + const missing = await handler( + new Request("https://updates.hunk.dev/other"), + { RELEASE_METADATA: namespace }, + context, + ); + expect(missing.status).toBe(404); + expect(missing.headers.get("cache-control")).toBe("no-store"); + await handler( new Request("https://updates.hunk.dev/v1/curl/latest?ignored=secret", { headers: { @@ -170,21 +314,18 @@ describe("release proxy Worker", () => { "x-other": "secret", }, }), - {}, + { RELEASE_METADATA: namespace }, context, ); - for (const currentVersion of [ - "not a version with private text", - "1.2.3-private-repository-name", - ]) { + for (const version of ["not a version with private text", "1.2.3-private-repository-name"]) { await handler( - new Request("https://updates.hunk.dev/v1/curl/latest", { + releaseRequest({ headers: { - "x-hunk-current-version": currentVersion, + "x-hunk-current-version": version, "x-hunk-request-source": "private-source", }, }), - {}, + { RELEASE_METADATA: namespace }, context, ); } diff --git a/workers/release-proxy/src/index.ts b/workers/release-proxy/src/index.ts index 3081caf7e..d76b23192 100644 --- a/workers/release-proxy/src/index.ts +++ b/workers/release-proxy/src/index.ts @@ -1,30 +1,62 @@ const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest"; const RELEASE_ROUTE = "/v1/curl/latest"; -const UPSTREAM_CACHE_CONTROL = "public, max-age=300"; +const RELEASE_METADATA_KEY = "latest-stable-release"; const CLIENT_CACHE_CONTROL = "no-store"; const UPSTREAM_TIMEOUT_MS = 5_000; +const METADATA_HEARTBEAT_MS = 60 * 60 * 1_000; +const MAX_METADATA_AGE_MS = 6 * 60 * 60 * 1_000; +const MAX_VERSION_LENGTH = 64; const REQUEST_SOURCES = ["install", "startup", "update-check", "update"] as const; type RequestSource = (typeof REQUEST_SOURCES)[number] | "unknown"; -interface WorkerExecutionContext { - waitUntil(promise: Promise): void; +interface ReleaseMetadata { + version: string; + checkedAt: number; +} + +interface ReleaseMetadataNamespace { + get(key: string): Promise; + put(key: string, value: string): Promise; } -interface WorkerCache { - match(request: Request): Promise; - put(request: Request, response: Response): Promise; +interface ReleaseProxyEnv { + RELEASE_METADATA: ReleaseMetadataNamespace; + GITHUB_TOKEN?: string; +} + +interface ScheduledController { + scheduledTime: number; + cron: string; +} + +interface WorkerExecutionContext { + waitUntil(promise: Promise): void; } type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; interface ReleaseProxyDeps { fetchImpl?: FetchImpl; - cache?: WorkerCache; log?: (entry: string) => void; + now?: () => number; upstreamTimeoutMs?: number; } +type RefreshFailureReason = + | "upstream_unavailable" + | "invalid_upstream_response" + | "storage_unavailable"; + +type RefreshResult = + | { status: "updated" | "unchanged"; version: string } + | { status: "failed"; reason: RefreshFailureReason; upstreamStatus?: number }; + +/** Return whether a value is one bounded stable Hunk version. */ +function isStableVersion(value: string) { + return value.length <= MAX_VERSION_LENGTH && /^\d{1,9}\.\d{1,9}\.\d{1,9}$/.test(value); +} + /** Return a bounded request source suitable for aggregate release-check logs. */ function requestSource(request: Request): RequestSource { const candidate = request.headers.get("x-hunk-request-source"); @@ -34,7 +66,11 @@ function requestSource(request: Request): RequestSource { /** Return a normalized Hunk version without admitting arbitrary values into structured logs. */ function currentVersion(request: Request) { const candidate = request.headers.get("x-hunk-current-version"); - return candidate && /^\d+\.\d+\.\d+(?:-beta\.\d+)?$/.test(candidate) ? candidate : "unknown"; + return candidate && + candidate.length <= MAX_VERSION_LENGTH && + /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-beta\.\d{1,9})?$/.test(candidate) + ? candidate + : "unknown"; } /** Read the stable version from GitHub's latest-release payload. */ @@ -49,51 +85,149 @@ function stableVersion(payload: unknown) { } const version = tagName.startsWith("v") ? tagName.slice(1) : tagName; - return /^\d+\.\d+\.\d+$/.test(version) ? version : undefined; + return isStableVersion(version) ? version : undefined; +} + +/** Parse one validated release record from global storage. */ +function releaseMetadata(value: string | null): ReleaseMetadata | undefined { + if (!value) return undefined; + + try { + const payload = JSON.parse(value) as unknown; + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined; + const record = payload as Record; + if ( + typeof record.version !== "string" || + !isStableVersion(record.version) || + typeof record.checkedAt !== "number" || + !Number.isSafeInteger(record.checkedAt) || + record.checkedAt < 0 + ) { + return undefined; + } + return { version: record.version, checkedAt: record.checkedAt }; + } catch { + return undefined; + } +} + +/** Return whether stored metadata is recent enough to suppress direct-GitHub fallback. */ +function isFreshMetadata(metadata: ReleaseMetadata, now: number) { + const age = now - metadata.checkedAt; + return age >= 0 && age <= MAX_METADATA_AGE_MS; } -/** Build one JSON response with explicit edge and client caching policy. */ +/** Build one JSON response that clients and outer caches must not retain. */ function jsonResponse(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status, headers: { - "cache-control": status === 200 ? UPSTREAM_CACHE_CONTROL : CLIENT_CACHE_CONTROL, + "cache-control": CLIENT_CACHE_CONTROL, "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff", }, }); } -/** Prevent Cloudflare's outer cache from bypassing per-request aggregate logging. */ -function clientResponse(response: Response) { - const headers = new Headers(response.headers); - headers.set("cache-control", CLIENT_CACHE_CONTROL); - return new Response(response.body, { status: response.status, headers }); -} +/** Fetch, validate, and persist the latest release without replacing last-known good metadata. */ +async function refreshReleaseVersion( + env: ReleaseProxyEnv, + deps: Required, +): Promise { + let previous: ReleaseMetadata | undefined; + try { + previous = releaseMetadata(await env.RELEASE_METADATA.get(RELEASE_METADATA_KEY)); + } catch { + return { status: "failed", reason: "storage_unavailable" }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), deps.upstreamTimeoutMs); + + try { + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "hunk-release-proxy", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`; + } -/** Resolve Cloudflare's default cache without requiring it in direct unit tests. */ -function defaultWorkerCache() { - return ( - globalThis as typeof globalThis & { - caches?: { default?: WorkerCache }; + const fetchImpl = deps.fetchImpl; + const upstream = await fetchImpl(GITHUB_LATEST_RELEASE_URL, { + headers, + signal: controller.signal, + }); + if (!upstream.ok) { + return { + status: "failed", + reason: "upstream_unavailable", + upstreamStatus: upstream.status, + }; } - ).caches?.default; + + let payload: unknown; + try { + payload = await upstream.json(); + } catch { + return { + status: "failed", + reason: controller.signal.aborted ? "upstream_unavailable" : "invalid_upstream_response", + }; + } + + const version = stableVersion(payload); + if (!version) { + return { status: "failed", reason: "invalid_upstream_response" }; + } + + const now = deps.now(); + if ( + previous?.version === version && + now >= previous.checkedAt && + now - previous.checkedAt < METADATA_HEARTBEAT_MS + ) { + return { status: "unchanged", version }; + } + + try { + await env.RELEASE_METADATA.put( + RELEASE_METADATA_KEY, + JSON.stringify({ version, checkedAt: now } satisfies ReleaseMetadata), + ); + } catch { + return { status: "failed", reason: "storage_unavailable" }; + } + return { status: "updated", version }; + } catch { + return { status: "failed", reason: "upstream_unavailable" }; + } finally { + clearTimeout(timeout); + } } -/** Serve normalized curl release metadata while logging only bounded aggregate dimensions. */ +/** Resolve dependencies once for direct tests and the deployed Worker entry points. */ +function resolveDeps(deps: ReleaseProxyDeps): Required { + return { + fetchImpl: deps.fetchImpl ?? fetch, + log: deps.log ?? console.log, + now: deps.now ?? Date.now, + upstreamTimeoutMs: deps.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS, + }; +} + +/** Serve fresh global metadata without coupling request traffic to GitHub. */ export function createReleaseProxyHandler(deps: ReleaseProxyDeps = {}) { - const fetchImpl = deps.fetchImpl ?? fetch; - const cache = deps.cache ?? defaultWorkerCache(); - const log = deps.log ?? console.log; - const upstreamTimeoutMs = deps.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS; + const resolved = resolveDeps(deps); - return async (request: Request, _env: unknown, ctx: WorkerExecutionContext) => { + return async (request: Request, env: ReleaseProxyEnv, _ctx: WorkerExecutionContext) => { const url = new URL(request.url); if (request.method !== "GET" || url.pathname !== RELEASE_ROUTE) { return jsonResponse({ error: "not_found" }, 404); } - log( + resolved.log( JSON.stringify({ event: "release_check", source: requestSource(request), @@ -101,52 +235,47 @@ export function createReleaseProxyHandler(deps: ReleaseProxyDeps = {}) { }), ); - const cacheKey = new Request(`${url.origin}${RELEASE_ROUTE}`); - const cached = await cache?.match(cacheKey); - if (cached) { - return clientResponse(cached); - } - - let upstream: Response; - let payload: unknown; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), upstreamTimeoutMs); + let stored: ReleaseMetadata | undefined; try { - upstream = await fetchImpl(GITHUB_LATEST_RELEASE_URL, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "hunk-release-proxy", - }, - signal: controller.signal, - }); - if (!upstream.ok) { - return jsonResponse({ error: "upstream_unavailable" }, 502); - } - - try { - payload = await upstream.json(); - } catch { - return jsonResponse({ error: "invalid_upstream_response" }, 502); - } + stored = releaseMetadata(await env.RELEASE_METADATA.get(RELEASE_METADATA_KEY)); } catch { - return jsonResponse({ error: "upstream_unavailable" }, 502); - } finally { - clearTimeout(timeout); + return jsonResponse({ error: "storage_unavailable" }, 502); } - const version = stableVersion(payload); - if (!version) { - return jsonResponse({ error: "invalid_upstream_response" }, 502); + if (!stored || !isFreshMetadata(stored, resolved.now())) { + return jsonResponse({ error: "metadata_unavailable" }, 503); } - const response = jsonResponse({ version }); - if (cache) { - ctx.waitUntil(cache.put(cacheKey, response.clone())); - } - return clientResponse(response); + return jsonResponse({ version: stored.version }); + }; +} + +/** Refresh global release metadata on the Worker's cron without discarding stale valid data. */ +export function createReleaseProxyScheduledHandler(deps: ReleaseProxyDeps = {}) { + const resolved = resolveDeps(deps); + + return async ( + _controller: ScheduledController, + env: ReleaseProxyEnv, + _ctx: WorkerExecutionContext, + ) => { + const result = await refreshReleaseVersion(env, resolved); + resolved.log( + JSON.stringify({ + event: "release_refresh", + status: result.status, + ...(result.status === "failed" + ? { + reason: result.reason, + ...(result.upstreamStatus ? { upstreamStatus: result.upstreamStatus } : {}), + } + : { version: result.version }), + }), + ); }; } export default { fetch: createReleaseProxyHandler(), + scheduled: createReleaseProxyScheduledHandler(), }; diff --git a/workers/release-proxy/wrangler.jsonc b/workers/release-proxy/wrangler.jsonc index 6ccb6853f..cd6f2a742 100644 --- a/workers/release-proxy/wrangler.jsonc +++ b/workers/release-proxy/wrangler.jsonc @@ -3,6 +3,25 @@ "name": "hunk-release-proxy", "main": "src/index.ts", "compatibility_date": "2026-08-01", - "routes": [{ "pattern": "updates.hunk.dev", "custom_domain": true }], - "observability": { "enabled": true }, + "routes": [ + { + "pattern": "updates.hunk.dev", + "custom_domain": true, + }, + ], + "observability": { + "enabled": true, + }, + "secrets": { + "required": ["GITHUB_TOKEN"], + }, + "triggers": { + "crons": ["* * * * *"], + }, + "kv_namespaces": [ + { + "binding": "RELEASE_METADATA", + "id": "003a65eac0354e3f8f6f53bcd3b09949", + }, + ], }