Skip to content

Commit edf9b17

Browse files
feat: serve optional GPG signature URLs for OTA releases (#54)
* 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 <noreply@anthropic.com> * fix releases sig URL cache typing --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8596c72 commit edf9b17

2 files changed

Lines changed: 218 additions & 23 deletions

File tree

src/releases.ts

Lines changed: 98 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ const releaseCache = new LRUCache<string, ReleaseMetadata>({
101101
ttl: 5 * 60 * 1000, // 5 minutes
102102
});
103103

104+
const MISSING_SIG_URL = false;
105+
106+
const sigUrlCache = new LRUCache<string, string | typeof MISSING_SIG_URL>({
107+
max: 1000,
108+
ttl: 5 * 60 * 1000, // 5 minutes
109+
});
110+
104111
const redirectCache = new LRUCache<string, string>({
105112
max: 1000,
106113
ttl: 5 * 60 * 1000, // 5 minutes
@@ -110,6 +117,7 @@ const redirectCache = new LRUCache<string, string>({
110117
export function clearCaches() {
111118
releaseCache.clear();
112119
redirectCache.clear();
120+
sigUrlCache.clear();
113121
}
114122

115123
const bucketName = process.env.R2_BUCKET;
@@ -203,6 +211,67 @@ async function resolveArtifactPath(
203211
);
204212
}
205213

214+
/**
215+
* Resolves the signature URL for a given version if a .sig file exists in S3.
216+
* Results are cached for 5 minutes.
217+
*/
218+
async function resolveSigUrl(
219+
prefix: "app" | "system",
220+
version: string,
221+
sku: string,
222+
): Promise<string | undefined> {
223+
const cacheKey = `${prefix}-${version}-${sku}`;
224+
const cached = sigUrlCache.get(cacheKey);
225+
if (cached !== undefined) return cached === MISSING_SIG_URL ? undefined : cached;
226+
227+
try {
228+
const path = await resolveArtifactPath(prefix, version, sku);
229+
const sigKey = `${path}.sig`;
230+
if (await s3ObjectExists(sigKey)) {
231+
const url = `${baseUrl}/${sigKey}`;
232+
sigUrlCache.set(cacheKey, url);
233+
return url;
234+
}
235+
} catch (error) {
236+
if (error instanceof NotFoundError) {
237+
// Version doesn't exist for this SKU — cache as absent
238+
sigUrlCache.set(cacheKey, MISSING_SIG_URL);
239+
return undefined;
240+
}
241+
// Don't cache transient errors (network, permissions, etc.)
242+
throw error;
243+
}
244+
245+
sigUrlCache.set(cacheKey, MISSING_SIG_URL);
246+
return undefined;
247+
}
248+
249+
/**
250+
* Enriches a Release response with signature URLs by checking S3 for .sig files.
251+
* Transient S3 errors are logged but don't block the response — sigUrl is optional.
252+
*/
253+
async function enrichWithSigUrls(release: Release, sku: string): Promise<void> {
254+
const [appSigUrl, systemSigUrl] = await Promise.all([
255+
release.appVersion
256+
? resolveSigUrl("app", release.appVersion, sku).catch(e => {
257+
console.error(`Failed to resolve app sig URL for ${release.appVersion}:`, e);
258+
return undefined;
259+
})
260+
: undefined,
261+
release.systemVersion
262+
? resolveSigUrl("system", release.systemVersion, sku).catch(e => {
263+
console.error(
264+
`Failed to resolve system sig URL for ${release.systemVersion}:`,
265+
e,
266+
);
267+
return undefined;
268+
})
269+
: undefined,
270+
]);
271+
if (appSigUrl) release.appSigUrl = appSigUrl;
272+
if (systemSigUrl) release.systemSigUrl = systemSigUrl;
273+
}
274+
206275
async function getLatestVersion(
207276
prefix: "app" | "system",
208277
includePrerelease: boolean,
@@ -257,7 +326,7 @@ async function getLatestVersion(
257326
const hash = await streamToString(hashResponse.Body);
258327

259328
// Cache the release metadata
260-
const release = {
329+
const release: ReleaseMetadata = {
261330
version: latestVersion,
262331
url,
263332
hash,
@@ -272,12 +341,14 @@ interface Release {
272341
appVersion: string;
273342
appUrl: string;
274343
appHash: string;
344+
appSigUrl?: string;
275345
appCachedAt?: number;
276346
appMaxSatisfying?: string;
277347

278348
systemVersion: string;
279349
systemUrl: string;
280350
systemHash: string;
351+
systemSigUrl?: string;
281352
systemCachedAt?: number;
282353
systemMaxSatisfying?: string;
283354
}
@@ -387,6 +458,7 @@ export async function Retrieve(req: Request, res: Response) {
387458

388459
// If the version isn't a wildcard, we skip the rollout percentage check
389460
if (query.prerelease || skipRollout) {
461+
await enrichWithSigUrls(remoteRelease, query.sku);
390462
return res.json(remoteRelease);
391463
}
392464

@@ -423,32 +495,37 @@ export async function Retrieve(req: Request, res: Response) {
423495
This occurs when a user manually checks for updates in the app UI.
424496
Background update checks follow the normal rollout percentage rules, to ensure controlled, gradual deployment of updates.
425497
*/
498+
let responseJson: Release;
426499
if (query.forceUpdate) {
427-
return res.json(toRelease(latestAppRelease, latestSystemRelease));
428-
}
500+
responseJson = toRelease(latestAppRelease, latestSystemRelease);
501+
} else {
502+
const defaultAppRelease = await getDefaultRelease("app");
503+
const defaultSystemRelease = await getDefaultRelease("system");
429504

430-
const defaultAppRelease = await getDefaultRelease("app");
431-
const defaultSystemRelease = await getDefaultRelease("system");
505+
responseJson = toRelease(defaultAppRelease, defaultSystemRelease);
432506

433-
const responseJson = toRelease(defaultAppRelease, defaultSystemRelease);
507+
if (
508+
await isDeviceEligibleForLatestRelease(
509+
latestAppRelease.rolloutPercentage,
510+
query.deviceId,
511+
)
512+
) {
513+
setAppRelease(responseJson, latestAppRelease);
514+
}
434515

435-
if (
436-
await isDeviceEligibleForLatestRelease(
437-
latestAppRelease.rolloutPercentage,
438-
query.deviceId,
439-
)
440-
) {
441-
setAppRelease(responseJson, latestAppRelease);
516+
if (
517+
await isDeviceEligibleForLatestRelease(
518+
latestSystemRelease.rolloutPercentage,
519+
query.deviceId,
520+
)
521+
) {
522+
setSystemRelease(responseJson, latestSystemRelease);
523+
}
442524
}
443525

444-
if (
445-
await isDeviceEligibleForLatestRelease(
446-
latestSystemRelease.rolloutPercentage,
447-
query.deviceId,
448-
)
449-
) {
450-
setSystemRelease(responseJson, latestSystemRelease);
451-
}
526+
// DB records don't store sigUrl. Resolve from S3 for the versions being served.
527+
// The device requires sigUrl for stable (non-prerelease) GPG signature verification.
528+
await enrichWithSigUrls(responseJson, query.sku);
452529

453530
return res.json(responseJson);
454531
}

test/releases.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,24 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) {
4747
}
4848

4949
// Mock S3 hash file response for legacy versions (no SKU support)
50-
function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) {
50+
function mockS3HashFile(prefix: "app" | "system", version: string, hash: string, opts?: { hasSig?: boolean }) {
5151
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
52+
const artifactPath = `${prefix}/${version}/${fileName}`;
5253

5354
// Mock versionHasSkuSupport to return false (no SKU folders)
5455
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
5556
Contents: [],
5657
});
5758

5859
// Mock legacy hash path
59-
s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({
60+
s3Mock.on(GetObjectCommand, { Key: `${artifactPath}.sha256` }).resolves({
6061
Body: createAsyncIterable(hash) as any,
6162
});
63+
64+
// Mock .sig existence check (absence handled by default HeadObject reject in beforeEach)
65+
if (opts?.hasSig) {
66+
s3Mock.on(HeadObjectCommand, { Key: `${artifactPath}.sig` }).resolves({});
67+
}
6268
}
6369

6470
// Mock S3 for versions with SKU support
@@ -67,6 +73,7 @@ function mockS3SkuVersion(
6773
version: string,
6874
sku: string,
6975
hash: string,
76+
opts?: { hasSig?: boolean },
7077
) {
7178
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
7279
const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`;
@@ -83,6 +90,11 @@ function mockS3SkuVersion(
8390
s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({
8491
Body: createAsyncIterable(hash) as any,
8592
});
93+
94+
// Mock .sig existence check (absence handled by default HeadObject reject in beforeEach)
95+
if (opts?.hasSig) {
96+
s3Mock.on(HeadObjectCommand, { Key: `${skuPath}.sig` }).resolves({});
97+
}
8698
}
8799

88100

@@ -161,6 +173,9 @@ function findDeviceIdInsideRollout(threshold: number) {
161173
describe("Retrieve handler", () => {
162174
beforeEach(() => {
163175
s3Mock.reset();
176+
// Default: .sig files don't exist unless explicitly mocked per-key.
177+
// More specific .on(HeadObjectCommand, { Key }) mocks take precedence.
178+
s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } });
164179
clearCaches();
165180
});
166181

@@ -451,6 +466,68 @@ describe("Retrieve handler", () => {
451466
});
452467
});
453468

469+
describe("signature URL handling", () => {
470+
it("should include sigUrl when .sig file exists", async () => {
471+
const req = createMockRequest({
472+
deviceId: "device-sig",
473+
prerelease: "true",
474+
appVersion: "^6.0.0",
475+
systemVersion: "^6.0.0",
476+
});
477+
const res = createMockResponse();
478+
479+
mockS3ListVersions("app", ["6.0.0"]);
480+
mockS3ListVersions("system", ["6.0.0"]);
481+
mockS3HashFile("app", "6.0.0", "sig-app-hash", { hasSig: true });
482+
mockS3HashFile("system", "6.0.0", "sig-system-hash", { hasSig: true });
483+
484+
await Retrieve(req, res);
485+
486+
expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/6.0.0/jetkvm_app.sig");
487+
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/6.0.0/system.tar.sig");
488+
});
489+
490+
it("should omit sigUrl when .sig file does not exist", async () => {
491+
const req = createMockRequest({
492+
deviceId: "device-nosig",
493+
prerelease: "true",
494+
appVersion: "^7.0.0",
495+
systemVersion: "^7.0.0",
496+
});
497+
const res = createMockResponse();
498+
499+
mockS3ListVersions("app", ["7.0.0"]);
500+
mockS3ListVersions("system", ["7.0.0"]);
501+
mockS3HashFile("app", "7.0.0", "nosig-app-hash");
502+
mockS3HashFile("system", "7.0.0", "nosig-system-hash");
503+
504+
await Retrieve(req, res);
505+
506+
expect(res._json.appSigUrl).toBeUndefined();
507+
expect(res._json.systemSigUrl).toBeUndefined();
508+
});
509+
510+
it("should include sigUrl with SKU path when .sig file exists", async () => {
511+
const req = createMockRequest({
512+
deviceId: "device-sku-sig",
513+
sku: "jetkvm-2",
514+
appVersion: "^8.0.0",
515+
systemVersion: "^8.0.0",
516+
});
517+
const res = createMockResponse();
518+
519+
mockS3ListVersions("app", ["8.0.0"]);
520+
mockS3ListVersions("system", ["8.0.0"]);
521+
mockS3SkuVersion("app", "8.0.0", "jetkvm-2", "sku-sig-app-hash", { hasSig: true });
522+
mockS3SkuVersion("system", "8.0.0", "jetkvm-2", "sku-sig-system-hash", { hasSig: true });
523+
524+
await Retrieve(req, res);
525+
526+
expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/8.0.0/skus/jetkvm-2/jetkvm_app.sig");
527+
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig");
528+
});
529+
});
530+
454531
describe("forceUpdate mode", () => {
455532
it("should return latest release when forceUpdate=true", async () => {
456533
// Use unique version constraints to get unique cache keys
@@ -473,6 +550,25 @@ describe("Retrieve handler", () => {
473550
expect(res._json.appVersion).toBe("1.5.5");
474551
expect(res._json.systemVersion).toBe("1.5.5");
475552
});
553+
554+
it("should include sigUrl when forceUpdate=true and .sig file exists", async () => {
555+
const req = createMockRequest({
556+
deviceId: "device-force-sig",
557+
forceUpdate: "true",
558+
});
559+
const res = createMockResponse();
560+
561+
mockS3ListVersions("app", ["10.0.0"]);
562+
mockS3ListVersions("system", ["10.0.0"]);
563+
mockS3HashFile("app", "10.0.0", "force-sig-app-hash", { hasSig: true });
564+
mockS3HashFile("system", "10.0.0", "force-sig-system-hash", { hasSig: true });
565+
566+
await Retrieve(req, res);
567+
568+
expect(res._json.appVersion).toBe("10.0.0");
569+
expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/10.0.0/jetkvm_app.sig");
570+
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/10.0.0/system.tar.sig");
571+
});
476572
});
477573

478574
describe("rollout logic", () => {
@@ -571,6 +667,28 @@ describe("Retrieve handler", () => {
571667
expect(res._json.appVersion).toBe("1.2.0");
572668
expect(res._json.systemVersion).toBe("1.1.0");
573669
});
670+
671+
it("should include sigUrl for rollout-eligible device when .sig file exists", async () => {
672+
await setRollout("1.1.0", "app", 100);
673+
await setRollout("1.1.0", "system", 100);
674+
await setRollout("1.2.0", "app", 100);
675+
await setRollout("1.2.0", "system", 100);
676+
677+
const deviceId = findDeviceIdInsideRollout(100);
678+
const req = createMockRequest({ deviceId });
679+
const res = createMockResponse();
680+
681+
mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]);
682+
mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]);
683+
mockS3HashFile("app", "1.2.0", "rollout-sig-app-hash", { hasSig: true });
684+
mockS3HashFile("system", "1.2.0", "rollout-sig-system-hash", { hasSig: true });
685+
686+
await Retrieve(req, res);
687+
688+
expect(res._json.appVersion).toBe("1.2.0");
689+
expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/1.2.0/jetkvm_app.sig");
690+
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/1.2.0/system.tar.sig");
691+
});
574692
});
575693

576694
describe("default release handling", () => {

0 commit comments

Comments
 (0)