From facc3b4a5d5203fdaa3e8e688cf06db8b8a6bf2c Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 6 Mar 2026 14:30:24 +0100 Subject: [PATCH 1/2] feat: serve optional GPG signature URLs for OTA releases Resolve .sig file existence from S3 at response time and include appSigUrl/systemSigUrl in the release payload when present. Works across all code paths (prerelease, forceUpdate, rollout) and supports backfilling signatures for older releases. Co-Authored-By: Claude Opus 4.6 --- src/releases.ts | 117 ++++++++++++++++++++++++++++++++-------- test/releases.test.ts | 122 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 216 insertions(+), 23 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 39d590c..6582806 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -101,6 +101,11 @@ const releaseCache = new LRUCache({ ttl: 5 * 60 * 1000, // 5 minutes }); +const sigUrlCache = new LRUCache({ + max: 1000, + ttl: 5 * 60 * 1000, // 5 minutes +}); + const redirectCache = new LRUCache({ max: 1000, ttl: 5 * 60 * 1000, // 5 minutes @@ -110,6 +115,7 @@ const redirectCache = new LRUCache({ export function clearCaches() { releaseCache.clear(); redirectCache.clear(); + sigUrlCache.clear(); } const bucketName = process.env.R2_BUCKET; @@ -203,6 +209,67 @@ async function resolveArtifactPath( ); } +/** + * Resolves the signature URL for a given version if a .sig file exists in S3. + * Results are cached for 5 minutes. + */ +async function resolveSigUrl( + prefix: "app" | "system", + version: string, + sku: string, +): Promise { + const cacheKey = `${prefix}-${version}-${sku}`; + const cached = sigUrlCache.get(cacheKey); + if (cached !== undefined) return cached ?? undefined; + + try { + const path = await resolveArtifactPath(prefix, version, sku); + const sigKey = `${path}.sig`; + if (await s3ObjectExists(sigKey)) { + const url = `${baseUrl}/${sigKey}`; + sigUrlCache.set(cacheKey, url); + return url; + } + } catch (error) { + if (error instanceof NotFoundError) { + // Version doesn't exist for this SKU — cache as absent + sigUrlCache.set(cacheKey, null); + return undefined; + } + // Don't cache transient errors (network, permissions, etc.) + throw error; + } + + sigUrlCache.set(cacheKey, null); + return undefined; +} + +/** + * Enriches a Release response with signature URLs by checking S3 for .sig files. + * Transient S3 errors are logged but don't block the response — sigUrl is optional. + */ +async function enrichWithSigUrls(release: Release, sku: string): Promise { + const [appSigUrl, systemSigUrl] = await Promise.all([ + release.appVersion + ? resolveSigUrl("app", release.appVersion, sku).catch(e => { + console.error(`Failed to resolve app sig URL for ${release.appVersion}:`, e); + return undefined; + }) + : undefined, + release.systemVersion + ? resolveSigUrl("system", release.systemVersion, sku).catch(e => { + console.error( + `Failed to resolve system sig URL for ${release.systemVersion}:`, + e, + ); + return undefined; + }) + : undefined, + ]); + if (appSigUrl) release.appSigUrl = appSigUrl; + if (systemSigUrl) release.systemSigUrl = systemSigUrl; +} + async function getLatestVersion( prefix: "app" | "system", includePrerelease: boolean, @@ -257,7 +324,7 @@ async function getLatestVersion( const hash = await streamToString(hashResponse.Body); // Cache the release metadata - const release = { + const release: ReleaseMetadata = { version: latestVersion, url, hash, @@ -272,12 +339,14 @@ interface Release { appVersion: string; appUrl: string; appHash: string; + appSigUrl?: string; appCachedAt?: number; appMaxSatisfying?: string; systemVersion: string; systemUrl: string; systemHash: string; + systemSigUrl?: string; systemCachedAt?: number; systemMaxSatisfying?: string; } @@ -387,6 +456,7 @@ export async function Retrieve(req: Request, res: Response) { // If the version isn't a wildcard, we skip the rollout percentage check if (query.prerelease || skipRollout) { + await enrichWithSigUrls(remoteRelease, query.sku); return res.json(remoteRelease); } @@ -423,32 +493,37 @@ export async function Retrieve(req: Request, res: Response) { This occurs when a user manually checks for updates in the app UI. Background update checks follow the normal rollout percentage rules, to ensure controlled, gradual deployment of updates. */ + let responseJson: Release; if (query.forceUpdate) { - return res.json(toRelease(latestAppRelease, latestSystemRelease)); - } + responseJson = toRelease(latestAppRelease, latestSystemRelease); + } else { + const defaultAppRelease = await getDefaultRelease("app"); + const defaultSystemRelease = await getDefaultRelease("system"); - const defaultAppRelease = await getDefaultRelease("app"); - const defaultSystemRelease = await getDefaultRelease("system"); + responseJson = toRelease(defaultAppRelease, defaultSystemRelease); - const responseJson = toRelease(defaultAppRelease, defaultSystemRelease); + if ( + await isDeviceEligibleForLatestRelease( + latestAppRelease.rolloutPercentage, + query.deviceId, + ) + ) { + setAppRelease(responseJson, latestAppRelease); + } - if ( - await isDeviceEligibleForLatestRelease( - latestAppRelease.rolloutPercentage, - query.deviceId, - ) - ) { - setAppRelease(responseJson, latestAppRelease); + if ( + await isDeviceEligibleForLatestRelease( + latestSystemRelease.rolloutPercentage, + query.deviceId, + ) + ) { + setSystemRelease(responseJson, latestSystemRelease); + } } - if ( - await isDeviceEligibleForLatestRelease( - latestSystemRelease.rolloutPercentage, - query.deviceId, - ) - ) { - setSystemRelease(responseJson, latestSystemRelease); - } + // DB records don't store sigUrl. Resolve from S3 for the versions being served. + // The device requires sigUrl for stable (non-prerelease) GPG signature verification. + await enrichWithSigUrls(responseJson, query.sku); return res.json(responseJson); } diff --git a/test/releases.test.ts b/test/releases.test.ts index 9adf0cd..85eb617 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -47,8 +47,9 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { } // Mock S3 hash file response for legacy versions (no SKU support) -function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) { +function mockS3HashFile(prefix: "app" | "system", version: string, hash: string, opts?: { hasSig?: boolean }) { const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const artifactPath = `${prefix}/${version}/${fileName}`; // Mock versionHasSkuSupport to return false (no SKU folders) s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ @@ -56,9 +57,14 @@ function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) }); // Mock legacy hash path - s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({ + s3Mock.on(GetObjectCommand, { Key: `${artifactPath}.sha256` }).resolves({ Body: createAsyncIterable(hash) as any, }); + + // Mock .sig existence check (absence handled by default HeadObject reject in beforeEach) + if (opts?.hasSig) { + s3Mock.on(HeadObjectCommand, { Key: `${artifactPath}.sig` }).resolves({}); + } } // Mock S3 for versions with SKU support @@ -67,6 +73,7 @@ function mockS3SkuVersion( version: string, sku: string, hash: string, + opts?: { hasSig?: boolean }, ) { const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; @@ -83,6 +90,11 @@ function mockS3SkuVersion( s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({ Body: createAsyncIterable(hash) as any, }); + + // Mock .sig existence check (absence handled by default HeadObject reject in beforeEach) + if (opts?.hasSig) { + s3Mock.on(HeadObjectCommand, { Key: `${skuPath}.sig` }).resolves({}); + } } @@ -161,6 +173,9 @@ function findDeviceIdInsideRollout(threshold: number) { describe("Retrieve handler", () => { beforeEach(() => { s3Mock.reset(); + // Default: .sig files don't exist unless explicitly mocked per-key. + // More specific .on(HeadObjectCommand, { Key }) mocks take precedence. + s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); clearCaches(); }); @@ -451,6 +466,68 @@ describe("Retrieve handler", () => { }); }); + describe("signature URL handling", () => { + it("should include sigUrl when .sig file exists", async () => { + const req = createMockRequest({ + deviceId: "device-sig", + prerelease: "true", + appVersion: "^6.0.0", + systemVersion: "^6.0.0", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["6.0.0"]); + mockS3ListVersions("system", ["6.0.0"]); + mockS3HashFile("app", "6.0.0", "sig-app-hash", { hasSig: true }); + mockS3HashFile("system", "6.0.0", "sig-system-hash", { hasSig: true }); + + await Retrieve(req, res); + + expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/6.0.0/jetkvm_app.sig"); + expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/6.0.0/system.tar.sig"); + }); + + it("should omit sigUrl when .sig file does not exist", async () => { + const req = createMockRequest({ + deviceId: "device-nosig", + prerelease: "true", + appVersion: "^7.0.0", + systemVersion: "^7.0.0", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["7.0.0"]); + mockS3ListVersions("system", ["7.0.0"]); + mockS3HashFile("app", "7.0.0", "nosig-app-hash"); + mockS3HashFile("system", "7.0.0", "nosig-system-hash"); + + await Retrieve(req, res); + + expect(res._json.appSigUrl).toBeUndefined(); + expect(res._json.systemSigUrl).toBeUndefined(); + }); + + it("should include sigUrl with SKU path when .sig file exists", async () => { + const req = createMockRequest({ + deviceId: "device-sku-sig", + sku: "jetkvm-2", + appVersion: "^8.0.0", + systemVersion: "^8.0.0", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["8.0.0"]); + mockS3ListVersions("system", ["8.0.0"]); + mockS3SkuVersion("app", "8.0.0", "jetkvm-2", "sku-sig-app-hash", { hasSig: true }); + mockS3SkuVersion("system", "8.0.0", "jetkvm-2", "sku-sig-system-hash", { hasSig: true }); + + await Retrieve(req, res); + + expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/8.0.0/skus/jetkvm-2/jetkvm_app.sig"); + expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig"); + }); + }); + describe("forceUpdate mode", () => { it("should return latest release when forceUpdate=true", async () => { // Use unique version constraints to get unique cache keys @@ -473,6 +550,25 @@ describe("Retrieve handler", () => { expect(res._json.appVersion).toBe("1.5.5"); expect(res._json.systemVersion).toBe("1.5.5"); }); + + it("should include sigUrl when forceUpdate=true and .sig file exists", async () => { + const req = createMockRequest({ + deviceId: "device-force-sig", + forceUpdate: "true", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["10.0.0"]); + mockS3ListVersions("system", ["10.0.0"]); + mockS3HashFile("app", "10.0.0", "force-sig-app-hash", { hasSig: true }); + mockS3HashFile("system", "10.0.0", "force-sig-system-hash", { hasSig: true }); + + await Retrieve(req, res); + + expect(res._json.appVersion).toBe("10.0.0"); + expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/10.0.0/jetkvm_app.sig"); + expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/10.0.0/system.tar.sig"); + }); }); describe("rollout logic", () => { @@ -571,6 +667,28 @@ describe("Retrieve handler", () => { expect(res._json.appVersion).toBe("1.2.0"); expect(res._json.systemVersion).toBe("1.1.0"); }); + + it("should include sigUrl for rollout-eligible device when .sig file exists", async () => { + await setRollout("1.1.0", "app", 100); + await setRollout("1.1.0", "system", 100); + await setRollout("1.2.0", "app", 100); + await setRollout("1.2.0", "system", 100); + + const deviceId = findDeviceIdInsideRollout(100); + const req = createMockRequest({ deviceId }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); + mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); + mockS3HashFile("app", "1.2.0", "rollout-sig-app-hash", { hasSig: true }); + mockS3HashFile("system", "1.2.0", "rollout-sig-system-hash", { hasSig: true }); + + await Retrieve(req, res); + + expect(res._json.appVersion).toBe("1.2.0"); + expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/1.2.0/jetkvm_app.sig"); + expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/1.2.0/system.tar.sig"); + }); }); describe("default release handling", () => { From 0e4353773028525b5bc23f2de1c97df4c76bd9ee Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 6 Mar 2026 14:35:39 +0100 Subject: [PATCH 2/2] fix releases sig URL cache typing --- src/releases.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 6582806..703155b 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -101,7 +101,9 @@ const releaseCache = new LRUCache({ ttl: 5 * 60 * 1000, // 5 minutes }); -const sigUrlCache = new LRUCache({ +const MISSING_SIG_URL = false; + +const sigUrlCache = new LRUCache({ max: 1000, ttl: 5 * 60 * 1000, // 5 minutes }); @@ -220,7 +222,7 @@ async function resolveSigUrl( ): Promise { const cacheKey = `${prefix}-${version}-${sku}`; const cached = sigUrlCache.get(cacheKey); - if (cached !== undefined) return cached ?? undefined; + if (cached !== undefined) return cached === MISSING_SIG_URL ? undefined : cached; try { const path = await resolveArtifactPath(prefix, version, sku); @@ -233,14 +235,14 @@ async function resolveSigUrl( } catch (error) { if (error instanceof NotFoundError) { // Version doesn't exist for this SKU — cache as absent - sigUrlCache.set(cacheKey, null); + sigUrlCache.set(cacheKey, MISSING_SIG_URL); return undefined; } // Don't cache transient errors (network, permissions, etc.) throw error; } - sigUrlCache.set(cacheKey, null); + sigUrlCache.set(cacheKey, MISSING_SIG_URL); return undefined; }