From c09d184bd81d652ffd45b9ee09ffbc82cec3d7ff Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 12:49:43 +0100 Subject: [PATCH 01/18] initial ota sku support --- src/releases.ts | 102 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 20 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 14fc2dd..041f96e 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -5,9 +5,9 @@ import { createHash } from "crypto"; import semver from "semver"; import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; -import { LRUCache } from 'lru-cache'; +import { LRUCache } from "lru-cache"; -import { streamToString, streamToBuffer, toSemverRange, verifyHash } from "./helpers"; +import { streamToString, toSemverRange, verifyHash } from "./helpers"; export interface ReleaseMetadata { version: string; @@ -45,12 +45,55 @@ export function clearCaches() { const bucketName = process.env.R2_BUCKET; const baseUrl = process.env.R2_CDN_URL; +const DEFAULT_SKU = "jetkvm-1"; + +/** + * Checks if an object exists in S3/R2 by attempting a GetObjectCommand. + * Returns true if the object exists, false otherwise. + */ +async function s3ObjectExists(key: string): Promise { + try { + await s3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: key, + }), + ); + return true; + } catch (error: any) { + if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { + return false; + } + throw error; + } +} + +/** + * Resolves the artifact path for a given version and SKU. + * Tries SKU-specific path first, falls back to legacy path for older versions. + */ +async function resolveArtifactPath( + prefix: "app" | "system", + version: string, + sku: string, +): Promise { + const artifact = prefix === "app" ? "jetkvm_app" : "system.tar"; + const skuPath = `${prefix}/${version}/skus/${sku}/${artifact}`; + if (await s3ObjectExists(skuPath)) { + return skuPath; + } + + // Legacy path for versions uploaded before SKU support + return `${prefix}/${version}/${artifact}`; +} + async function getLatestVersion( prefix: "app" | "system", includePrerelease: boolean, maxSatisfying: string = "*", + sku: string = DEFAULT_SKU, ): Promise { - const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}`; + const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; const cached = releaseCache.get(cacheKey); if (cached) { return cached; @@ -82,16 +125,18 @@ async function getLatestVersion( includePrerelease, }) as string; if (!latestVersion) { - throw new NotFoundError(`No version found under prefix ${prefix} that satisfies ${maxSatisfying}`); + throw new NotFoundError( + `No version found under prefix ${prefix} that satisfies ${maxSatisfying}`, + ); } - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; - const url = `${baseUrl}/${prefix}/${latestVersion}/${fileName}`; + const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku); + const url = `${baseUrl}/${selectedPath}`; const hashResponse = await s3Client.send( new GetObjectCommand({ Bucket: bucketName, - Key: `${prefix}/${latestVersion}/${fileName}.sha256`, + Key: `${selectedPath}.sha256`, }), ); @@ -139,7 +184,10 @@ function setSystemRelease(release: Release, systemRelease: ReleaseMetadata) { release.systemMaxSatisfying = systemRelease._maxSatisfying; } -function toRelease(appRelease?: ReleaseMetadata, systemRelease?: ReleaseMetadata): Release { +function toRelease( + appRelease?: ReleaseMetadata, + systemRelease?: ReleaseMetadata, +): Release { const release: Partial = {}; if (appRelease) setAppRelease(release as Release, appRelease); if (systemRelease) setSystemRelease(release as Release, systemRelease); @@ -148,11 +196,15 @@ function toRelease(appRelease?: ReleaseMetadata, systemRelease?: ReleaseMetadata async function getReleaseFromS3( includePrerelease: boolean, - { appVersion, systemVersion }: { appVersion?: string; systemVersion?: string }, + { + appVersion, + systemVersion, + sku, + }: { appVersion?: string; systemVersion?: string; sku?: string }, ): Promise { const [appRelease, systemRelease] = await Promise.all([ - getLatestVersion("app", includePrerelease, appVersion), - getLatestVersion("system", includePrerelease, systemVersion), + getLatestVersion("app", includePrerelease, appVersion, sku), + getLatestVersion("system", includePrerelease, systemVersion, sku), ]); return toRelease(appRelease, systemRelease); @@ -210,10 +262,17 @@ export async function Retrieve(req: Request, res: Response) { const systemVersion = toSemverRange(req.query.systemVersion as string | undefined); const skipRollout = appVersion !== "*" || systemVersion !== "*"; + // Get SKU from query, default to jetkvm-1 for legacy devices + const sku = (req.query.sku as string | undefined) || DEFAULT_SKU; + // Get the latest release from S3 let remoteRelease: Release; try { - remoteRelease = await getReleaseFromS3(includePrerelease, { appVersion, systemVersion }); + remoteRelease = await getReleaseFromS3(includePrerelease, { + appVersion, + systemVersion, + sku, + }); } catch (error) { console.error(error); if (error instanceof NotFoundError) { @@ -266,9 +325,7 @@ export async function Retrieve(req: Request, res: Response) { */ const forceUpdate = req.query.forceUpdate === "true"; if (forceUpdate) { - return res.json( - toRelease(latestAppRelease, latestSystemRelease), - ); + return res.json(toRelease(latestAppRelease, latestSystemRelease)); } const defaultAppRelease = await getDefaultRelease("app"); @@ -294,7 +351,10 @@ export async function Retrieve(req: Request, res: Response) { return res.json(responseJson); } -function cachedRedirect(cachedKey: (req: Request) => string, callback: (req: Request) => Promise) { +function cachedRedirect( + cachedKey: (req: Request) => string, + callback: (req: Request) => Promise, +) { return async (req: Request, res: Response) => { const cacheKey = cachedKey(req); let result = redirectCache.get(cacheKey); @@ -307,7 +367,8 @@ function cachedRedirect(cachedKey: (req: Request) => string, callback: (req: Req } export const RetrieveLatestSystemRecovery = cachedRedirect( - (req: Request) => `system-recovery-${req.query.prerelease === "true" ? "pre" : "stable"}`, + (req: Request) => + `system-recovery-${req.query.prerelease === "true" ? "pre" : "stable"}`, async (req: Request) => { const includePrerelease = req.query.prerelease === "true"; @@ -384,8 +445,8 @@ export const RetrieveLatestApp = cachedRedirect( throw new NotFoundError("No app versions found"); } - const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter(v => - semver.valid(v), + const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter( + v => semver.valid(v), ); const latestVersion = semver.maxSatisfying(versions, "*", { @@ -420,4 +481,5 @@ export const RetrieveLatestApp = cachedRedirect( console.log("App hash matches", latestVersion); return `${baseUrl}/app/${latestVersion}/jetkvm_app`; - }); + }, +); From 25944c9610c547b6d438655eb2867d13e2ea4268 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 12:49:49 +0100 Subject: [PATCH 02/18] Fix tests --- test/releases.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/releases.test.ts b/test/releases.test.ts index 76e9ac9..0bd17e4 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -44,6 +44,12 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { // Mock S3 hash file response function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) { const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const defaultSku = "jetkvm-1"; + const skuPath = `${prefix}/${version}/skus/${defaultSku}/${fileName}`; + s3Mock.on(GetObjectCommand, { Key: skuPath }).rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({ Body: createAsyncIterable(hash) as any, }); From e61ed12442c22ee48fa1428b27c39bb233d9211a Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 14:22:26 +0100 Subject: [PATCH 03/18] Backwards compatible sku ota --- src/releases.ts | 83 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 041f96e..1021fa3 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -69,31 +69,72 @@ async function s3ObjectExists(key: string): Promise { } /** - * Resolves the artifact path for a given version and SKU. - * Tries SKU-specific path first, falls back to legacy path for older versions. + * Checks if a version was uploaded with SKU folder structure. + * Returns true if any skus/ subfolder exists for this version. + */ +async function versionHasSkuSupport( + prefix: "app" | "system", + version: string, +): Promise { + const response = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: `${prefix}/${version}/skus/`, + MaxKeys: 1, + }), + ); + return (response.Contents?.length ?? 0) > 0; +} + +/** + * Resolves the artifact path for a given version and optional SKU. + * + * For versions with SKU support (skus/ folder exists): + * - Uses the provided SKU, or defaults to DEFAULT_SKU + * - Fails if the requested SKU is not available + * + * For legacy versions (no skus/ folder): + * - Returns legacy path for default SKU or when no SKU specified + * - Fails for non-default SKUs because legacy firmware predates + * that hardware and may not be compatible */ async function resolveArtifactPath( prefix: "app" | "system", version: string, - sku: string, + sku: string | undefined, ): Promise { const artifact = prefix === "app" ? "jetkvm_app" : "system.tar"; - const skuPath = `${prefix}/${version}/skus/${sku}/${artifact}`; - if (await s3ObjectExists(skuPath)) { - return skuPath; + + if (await versionHasSkuSupport(prefix, version)) { + const targetSku = sku ?? DEFAULT_SKU; + const skuPath = `${prefix}/${version}/skus/${targetSku}/${artifact}`; + + if (await s3ObjectExists(skuPath)) { + return skuPath; + } + + throw new NotFoundError( + `SKU "${targetSku}" is not available for version ${version}`, + ); + } + + // Legacy version - only default SKU (or unspecified) is allowed + if (sku === undefined || sku === DEFAULT_SKU) { + return `${prefix}/${version}/${artifact}`; } - // Legacy path for versions uploaded before SKU support - return `${prefix}/${version}/${artifact}`; + throw new NotFoundError( + `Version ${version} predates SKU support and cannot serve SKU "${sku}"`, + ); } async function getLatestVersion( prefix: "app" | "system", includePrerelease: boolean, maxSatisfying: string = "*", - sku: string = DEFAULT_SKU, + sku?: string, ): Promise { - const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; + const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku ?? "default"}`; const cached = releaseCache.get(cacheKey); if (cached) { return cached; @@ -210,17 +251,22 @@ async function getReleaseFromS3( return toRelease(appRelease, systemRelease); } +/** + * Computes a deterministic rollout bucket (0-99) for a device ID. + * Used to decide if a device is eligible for a staged rollout. + */ +export function getDeviceRolloutBucket(deviceId: string): number { + const hash = createHash("md5").update(deviceId).digest("hex"); + const hashPrefix = hash.substring(0, 8); + return parseInt(hashPrefix, 16) % 100; +} + async function isDeviceEligibleForLatestRelease( rolloutPercentage: number, deviceId: string, ): Promise { if (rolloutPercentage === 100) return true; - - const hash = createHash("md5").update(deviceId).digest("hex"); - const hashPrefix = hash.substring(0, 8); - const hashValue = parseInt(hashPrefix, 16) % 100; - - return hashValue < rolloutPercentage; + return getDeviceRolloutBucket(deviceId) < rolloutPercentage; } async function getDefaultRelease(type: "app" | "system") { @@ -262,8 +308,9 @@ export async function Retrieve(req: Request, res: Response) { const systemVersion = toSemverRange(req.query.systemVersion as string | undefined); const skipRollout = appVersion !== "*" || systemVersion !== "*"; - // Get SKU from query, default to jetkvm-1 for legacy devices - const sku = (req.query.sku as string | undefined) || DEFAULT_SKU; + // Get SKU from query - undefined means use default with legacy fallback + const skuParam = req.query.sku as string | undefined; + const sku = skuParam === "" ? undefined : skuParam; // Get the latest release from S3 let remoteRelease: Release; From b62ae13deedffdeccffab144983a39c12634f2bc Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 14:22:33 +0100 Subject: [PATCH 04/18] Add SKU handling tests for legacy and supported versions in releases --- test/releases.test.ts | 175 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 16 deletions(-) diff --git a/test/releases.test.ts b/test/releases.test.ts index 0bd17e4..4578d3a 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -3,10 +3,15 @@ import { Request, Response } from "express"; import { GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; import { s3Mock, createAsyncIterable, testPrisma, seedReleases, setRollout, resetToSeedData } from "./setup"; import { BadRequestError, NotFoundError, InternalServerError } from "../src/errors"; -import { createHash } from "crypto"; // Import the module under test after setup -import { Retrieve, RetrieveLatestApp, RetrieveLatestSystemRecovery, clearCaches } from "../src/releases"; +import { + Retrieve, + RetrieveLatestApp, + RetrieveLatestSystemRecovery, + clearCaches, + getDeviceRolloutBucket, +} from "../src/releases"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -41,20 +46,45 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { }); } -// Mock S3 hash file response +// Mock S3 hash file response for legacy versions (no SKU support) function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) { const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; - const defaultSku = "jetkvm-1"; - const skuPath = `${prefix}/${version}/skus/${defaultSku}/${fileName}`; - s3Mock.on(GetObjectCommand, { Key: skuPath }).rejects({ - name: "NoSuchKey", - $metadata: { httpStatusCode: 404 }, + + // Mock versionHasSkuSupport to return false (no SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ + Contents: [], }); + + // Mock legacy hash path s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({ Body: createAsyncIterable(hash) as any, }); } +// Mock S3 for versions with SKU support +function mockS3SkuVersion( + prefix: "app" | "system", + version: string, + sku: string, + hash: string, +) { + const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; + + // Mock versionHasSkuSupport to return true (has SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ + Contents: [{ Key: skuPath }], + }); + + // Mock SKU artifact exists + s3Mock.on(GetObjectCommand, { Key: skuPath }).resolves({}); + + // Mock SKU hash path + s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({ + Body: createAsyncIterable(hash) as any, + }); +} + // Mock S3 file and hash for redirect endpoints function mockS3FileWithHash( prefix: "app" | "system", @@ -71,16 +101,10 @@ function mockS3FileWithHash( }); } -function rolloutBucket(deviceId: string) { - const hash = createHash("md5").update(deviceId).digest("hex"); - const hashPrefix = hash.substring(0, 8); - return parseInt(hashPrefix, 16) % 100; -} - function findDeviceIdOutsideRollout(threshold: number) { for (let i = 0; i < 10000; i += 1) { const candidate = `device-not-eligible-${i}`; - if (rolloutBucket(candidate) >= threshold) { + if (getDeviceRolloutBucket(candidate) >= threshold) { return candidate; } } @@ -90,7 +114,7 @@ function findDeviceIdOutsideRollout(threshold: number) { function findDeviceIdInsideRollout(threshold: number) { for (let i = 0; i < 10000; i += 1) { const candidate = `device-eligible-${i}`; - if (rolloutBucket(candidate) < threshold) { + if (getDeviceRolloutBucket(candidate) < threshold) { return candidate; } } @@ -251,6 +275,125 @@ describe("Retrieve handler", () => { }); }); + describe("SKU handling", () => { + it("should use legacy path when no SKU provided on legacy version", async () => { + const req = createMockRequest({ deviceId: "device-123" }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["1.0.0"]); + mockS3ListVersions("system", ["1.0.0"]); + mockS3HashFile("app", "1.0.0", "legacy-app-hash"); + mockS3HashFile("system", "1.0.0", "legacy-system-hash"); + + await Retrieve(req, res); + + expect(res._json.appVersion).toBe("1.0.0"); + expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + }); + + it("should use legacy path when default SKU provided on legacy version", async () => { + const req = createMockRequest({ deviceId: "device-123", sku: "jetkvm-1" }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["1.0.0"]); + mockS3ListVersions("system", ["1.0.0"]); + mockS3HashFile("app", "1.0.0", "legacy-app-hash-2"); + mockS3HashFile("system", "1.0.0", "legacy-system-hash-2"); + + await Retrieve(req, res); + + expect(res._json.appVersion).toBe("1.0.0"); + expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + }); + + it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { + const req = createMockRequest({ deviceId: "device-123", sku: "jetkvm-2" }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["1.0.0"]); + mockS3ListVersions("system", ["1.0.0"]); + mockS3HashFile("app", "1.0.0", "legacy-app-hash-3"); + mockS3HashFile("system", "1.0.0", "legacy-system-hash-3"); + + await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); + await expect(Retrieve(req, res)).rejects.toThrow("predates SKU support"); + }); + + it("should use SKU path when version has SKU support", async () => { + // Use version constraints to skip rollout logic + const req = createMockRequest({ + deviceId: "device-123", + sku: "jetkvm-2", + appVersion: "^2.0.0", + systemVersion: "^2.0.0", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["2.0.0"]); + mockS3ListVersions("system", ["2.0.0"]); + mockS3SkuVersion("app", "2.0.0", "jetkvm-2", "sku-app-hash"); + mockS3SkuVersion("system", "2.0.0", "jetkvm-2", "sku-system-hash"); + + await Retrieve(req, res); + + expect(res._json.appVersion).toBe("2.0.0"); + expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app"); + expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-2/system.tar"); + }); + + it("should use default SKU when no SKU provided on version with SKU support", async () => { + // Use version constraints to skip rollout logic + const req = createMockRequest({ + deviceId: "device-123", + appVersion: "^2.0.0", + systemVersion: "^2.0.0", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["2.0.0"]); + mockS3ListVersions("system", ["2.0.0"]); + mockS3SkuVersion("app", "2.0.0", "jetkvm-1", "default-sku-app-hash"); + mockS3SkuVersion("system", "2.0.0", "jetkvm-1", "default-sku-system-hash"); + + await Retrieve(req, res); + + expect(res._json.appVersion).toBe("2.0.0"); + expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-1/jetkvm_app"); + }); + + it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { + const req = createMockRequest({ + deviceId: "device-123", + sku: "jetkvm-3", + appVersion: "^2.0.0", + systemVersion: "^2.0.0", + }); + const res = createMockResponse(); + + mockS3ListVersions("app", ["2.0.0"]); + mockS3ListVersions("system", ["2.0.0"]); + + // Version has SKU support (jetkvm-1 exists) but jetkvm-3 doesn't + s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ + Contents: [{ Key: "app/2.0.0/skus/jetkvm-1/jetkvm_app" }], + }); + s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ + Contents: [{ Key: "system/2.0.0/skus/jetkvm-1/system.tar" }], + }); + s3Mock.on(GetObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); + s3Mock.on(GetObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/system.tar" }).rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); + + await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); + await expect(Retrieve(req, res)).rejects.toThrow("is not available for version"); + }); + }); + describe("forceUpdate mode", () => { it("should return latest release when forceUpdate=true", async () => { // Use unique version constraints to get unique cache keys From 2eb2b9714d74ae15a699bcb2769cb70c95121f44 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 14:31:52 +0100 Subject: [PATCH 05/18] Explicit test setup for byspassing pre-release tests --- test/releases.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/releases.test.ts b/test/releases.test.ts index 4578d3a..b0e8153 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -182,6 +182,8 @@ describe("Retrieve handler", () => { mockS3ListVersions("system", ["1.0.0", "1.1.0", "2.0.0-alpha.1"]); mockS3HashFile("app", "2.0.0-beta.1", "prerelease-app-hash"); mockS3HashFile("system", "2.0.0-alpha.1", "prerelease-system-hash"); + await setRollout("2.0.0-beta.1", "app", 0); + await setRollout("2.0.0-alpha.1", "system", 0); await Retrieve(req, res); @@ -205,6 +207,8 @@ describe("Retrieve handler", () => { mockS3ListVersions("system", ["3.0.0", "3.1.0-rc.1"]); mockS3HashFile("app", "3.1.0-rc.1", "rc-app-hash"); mockS3HashFile("system", "3.1.0-rc.1", "rc-system-hash"); + await setRollout("3.1.0-rc.1", "app", 0); + await setRollout("3.1.0-rc.1", "system", 0); await Retrieve(req, res); @@ -257,6 +261,8 @@ describe("Retrieve handler", () => { mockS3ListVersions("system", ["1.0.0", "2.0.0"]); mockS3HashFile("app", "1.0.0", "app-hash-100"); mockS3HashFile("system", "1.0.0", "system-hash-100"); + await setRollout("1.0.0", "app", 0); + await setRollout("1.0.0", "system", 0); await Retrieve(req, res); @@ -320,7 +326,6 @@ describe("Retrieve handler", () => { }); it("should use SKU path when version has SKU support", async () => { - // Use version constraints to skip rollout logic const req = createMockRequest({ deviceId: "device-123", sku: "jetkvm-2", @@ -342,7 +347,6 @@ describe("Retrieve handler", () => { }); it("should use default SKU when no SKU provided on version with SKU support", async () => { - // Use version constraints to skip rollout logic const req = createMockRequest({ deviceId: "device-123", appVersion: "^2.0.0", From 0ef25d6c1d36ae07d92f31f16eab2544bc571efa Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 14:46:40 +0100 Subject: [PATCH 06/18] Refactor hash verification and add device rollout bucket function --- src/helpers.ts | 14 +++++++++++++- src/releases.ts | 18 ++++++------------ test/releases.test.ts | 8 +++----- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 4212bff..453e040 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -32,7 +32,9 @@ export async function verifyHash( ): Promise { const content = await streamToBuffer(file.Body); const remoteHash = await streamToString(hashFile.Body); - const localHash = createHash("sha256").update(content).digest("hex"); + const localHash = createHash("sha256") + .update(new Uint8Array(content)) + .digest("hex"); const matches = remoteHash.trim() === localHash; if (!matches && exception) { @@ -44,4 +46,14 @@ export async function verifyHash( export function toSemverRange(range?: string) { if (!range) return "*"; return validRange(range) || "*"; +} + +/** + * Computes a deterministic rollout bucket (0-99) for a device ID. + * Used to decide if a device is eligible for a staged rollout. + */ +export function getDeviceRolloutBucket(deviceId: string): number { + const hash = createHash("md5").update(deviceId).digest("hex"); + const hashPrefix = hash.substring(0, 8); + return parseInt(hashPrefix, 16) % 100; } \ No newline at end of file diff --git a/src/releases.ts b/src/releases.ts index 1021fa3..ae27af1 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -1,13 +1,17 @@ import { Request, Response } from "express"; import { prisma } from "./db"; import { BadRequestError, InternalServerError, NotFoundError } from "./errors"; -import { createHash } from "crypto"; import semver from "semver"; import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; import { LRUCache } from "lru-cache"; -import { streamToString, toSemverRange, verifyHash } from "./helpers"; +import { + getDeviceRolloutBucket, + streamToString, + toSemverRange, + verifyHash, +} from "./helpers"; export interface ReleaseMetadata { version: string; @@ -251,16 +255,6 @@ async function getReleaseFromS3( return toRelease(appRelease, systemRelease); } -/** - * Computes a deterministic rollout bucket (0-99) for a device ID. - * Used to decide if a device is eligible for a staged rollout. - */ -export function getDeviceRolloutBucket(deviceId: string): number { - const hash = createHash("md5").update(deviceId).digest("hex"); - const hashPrefix = hash.substring(0, 8); - return parseInt(hashPrefix, 16) % 100; -} - async function isDeviceEligibleForLatestRelease( rolloutPercentage: number, deviceId: string, diff --git a/test/releases.test.ts b/test/releases.test.ts index b0e8153..059fabd 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -10,8 +10,8 @@ import { RetrieveLatestApp, RetrieveLatestSystemRecovery, clearCaches, - getDeviceRolloutBucket, } from "../src/releases"; +import { getDeviceRolloutBucket } from "../src/helpers"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -182,8 +182,6 @@ describe("Retrieve handler", () => { mockS3ListVersions("system", ["1.0.0", "1.1.0", "2.0.0-alpha.1"]); mockS3HashFile("app", "2.0.0-beta.1", "prerelease-app-hash"); mockS3HashFile("system", "2.0.0-alpha.1", "prerelease-system-hash"); - await setRollout("2.0.0-beta.1", "app", 0); - await setRollout("2.0.0-alpha.1", "system", 0); await Retrieve(req, res); @@ -207,8 +205,6 @@ describe("Retrieve handler", () => { mockS3ListVersions("system", ["3.0.0", "3.1.0-rc.1"]); mockS3HashFile("app", "3.1.0-rc.1", "rc-app-hash"); mockS3HashFile("system", "3.1.0-rc.1", "rc-system-hash"); - await setRollout("3.1.0-rc.1", "app", 0); - await setRollout("3.1.0-rc.1", "system", 0); await Retrieve(req, res); @@ -295,6 +291,7 @@ describe("Retrieve handler", () => { expect(res._json.appVersion).toBe("1.0.0"); expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + expect(res._json.systemUrl).toBe("https://cdn.test.com/system/1.0.0/system.tar"); }); it("should use legacy path when default SKU provided on legacy version", async () => { @@ -310,6 +307,7 @@ describe("Retrieve handler", () => { expect(res._json.appVersion).toBe("1.0.0"); expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + expect(res._json.systemUrl).toBe("https://cdn.test.com/system/1.0.0/system.tar"); }); it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { From 1bf90f9f6f45dea517099b14fc4c9f4077c3d574 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 14:55:54 +0100 Subject: [PATCH 07/18] Update SKU handling tests to pin app and system versions for consistent behavior --- test/releases.test.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/releases.test.ts b/test/releases.test.ts index 059fabd..419f49f 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -279,7 +279,12 @@ describe("Retrieve handler", () => { describe("SKU handling", () => { it("should use legacy path when no SKU provided on legacy version", async () => { - const req = createMockRequest({ deviceId: "device-123" }); + // Pin versions to bypass rollout; SKU behavior is the only variable here. + const req = createMockRequest({ + deviceId: "device-123", + appVersion: "1.0.0", + systemVersion: "1.0.0", + }); const res = createMockResponse(); mockS3ListVersions("app", ["1.0.0"]); @@ -295,7 +300,13 @@ describe("Retrieve handler", () => { }); it("should use legacy path when default SKU provided on legacy version", async () => { - const req = createMockRequest({ deviceId: "device-123", sku: "jetkvm-1" }); + // Pin versions to bypass rollout; SKU behavior is the only variable here. + const req = createMockRequest({ + deviceId: "device-123", + sku: "jetkvm-1", + appVersion: "1.0.0", + systemVersion: "1.0.0", + }); const res = createMockResponse(); mockS3ListVersions("app", ["1.0.0"]); @@ -311,7 +322,13 @@ describe("Retrieve handler", () => { }); it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { - const req = createMockRequest({ deviceId: "device-123", sku: "jetkvm-2" }); + // Pin versions to bypass rollout; SKU behavior is the only variable here. + const req = createMockRequest({ + deviceId: "device-123", + sku: "jetkvm-2", + appVersion: "1.0.0", + systemVersion: "1.0.0", + }); const res = createMockResponse(); mockS3ListVersions("app", ["1.0.0"]); From 59690116953b753769b999d4add1e05bf918b8a3 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:15:38 +0100 Subject: [PATCH 08/18] Add new SKU update suppport to latest system and app endpoints --- src/releases.ts | 47 +++++-- test/releases.test.ts | 315 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 346 insertions(+), 16 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index ae27af1..c123145 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -101,13 +101,19 @@ async function versionHasSkuSupport( * - Returns legacy path for default SKU or when no SKU specified * - Fails for non-default SKUs because legacy firmware predates * that hardware and may not be compatible + * + * @param prefix - The prefix folder ("app" or "system") + * @param version - The version string + * @param sku - Optional SKU identifier + * @param artifactOverride - Optional artifact name override (defaults based on prefix) */ async function resolveArtifactPath( prefix: "app" | "system", version: string, sku: string | undefined, + artifactOverride?: string, ): Promise { - const artifact = prefix === "app" ? "jetkvm_app" : "system.tar"; + const artifact = artifactOverride ?? (prefix === "app" ? "jetkvm_app" : "system.tar"); if (await versionHasSkuSupport(prefix, version)) { const targetSku = sku ?? DEFAULT_SKU; @@ -408,11 +414,18 @@ function cachedRedirect( } export const RetrieveLatestSystemRecovery = cachedRedirect( - (req: Request) => - `system-recovery-${req.query.prerelease === "true" ? "pre" : "stable"}`, + (req: Request) => { + const skuParam = req.query.sku as string | undefined; + const sku = skuParam === "" ? undefined : skuParam; + return `system-recovery-${req.query.prerelease === "true" ? "pre" : "stable"}-${sku ?? "default"}`; + }, async (req: Request) => { const includePrerelease = req.query.prerelease === "true"; + // Get SKU from query - undefined means use default with legacy fallback + const skuParam = req.query.sku as string | undefined; + const sku = skuParam === "" ? undefined : skuParam; + // Get the latest system recovery image from S3. It's stored in the system/ folder. const listCommand = new ListObjectsV2Command({ Bucket: bucketName, @@ -439,18 +452,21 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( throw new NotFoundError("No valid system recovery versions found"); } + // Resolve the artifact path with SKU support (using update.img for recovery) + const artifactPath = await resolveArtifactPath("system", latestVersion, sku, "update.img"); + const [firmwareFile, hashFile] = await Promise.all([ // TODO: store file hash using custom header to avoid extra request s3Client.send( new GetObjectCommand({ Bucket: bucketName, - Key: `system/${latestVersion}/update.img`, + Key: artifactPath, }), ), s3Client.send( new GetObjectCommand({ Bucket: bucketName, - Key: `system/${latestVersion}/update.img.sha256`, + Key: `${artifactPath}.sha256`, }), ), ]); @@ -465,15 +481,23 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( console.log("system recovery image hash matches", latestVersion); - return `${baseUrl}/system/${latestVersion}/update.img`; + return `${baseUrl}/${artifactPath}`; }, ); export const RetrieveLatestApp = cachedRedirect( - (req: Request) => `app-${req.query.prerelease === "true" ? "pre" : "stable"}`, + (req: Request) => { + const skuParam = req.query.sku as string | undefined; + const sku = skuParam === "" ? undefined : skuParam; + return `app-${req.query.prerelease === "true" ? "pre" : "stable"}-${sku ?? "default"}`; + }, async (req: Request) => { const includePrerelease = req.query.prerelease === "true"; + // Get SKU from query - undefined means use default with legacy fallback + const skuParam = req.query.sku as string | undefined; + const sku = skuParam === "" ? undefined : skuParam; + // Get the latest version const listCommand = new ListObjectsV2Command({ Bucket: bucketName, @@ -498,18 +522,21 @@ export const RetrieveLatestApp = cachedRedirect( throw new NotFoundError("No valid app versions found"); } + // Resolve the artifact path with SKU support + const artifactPath = await resolveArtifactPath("app", latestVersion, sku); + // Get the app file and its hash const [appFile, hashFile] = await Promise.all([ s3Client.send( new GetObjectCommand({ Bucket: bucketName, - Key: `app/${latestVersion}/jetkvm_app`, + Key: artifactPath, }), ), s3Client.send( new GetObjectCommand({ Bucket: bucketName, - Key: `app/${latestVersion}/jetkvm_app.sha256`, + Key: `${artifactPath}.sha256`, }), ), ]); @@ -521,6 +548,6 @@ export const RetrieveLatestApp = cachedRedirect( await verifyHash(appFile, hashFile, "app hash does not match"); console.log("App hash matches", latestVersion); - return `${baseUrl}/app/${latestVersion}/jetkvm_app`; + return `${baseUrl}/${artifactPath}`; }, ); diff --git a/test/releases.test.ts b/test/releases.test.ts index 419f49f..317a4e9 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -101,6 +101,55 @@ function mockS3FileWithHash( }); } +// Mock S3 for legacy version with file content (for redirect endpoints with hash verification) +function mockS3LegacyVersionWithContent( + prefix: "app" | "system", + version: string, + fileName: string, + content: string, + hash: string +) { + // Mock versionHasSkuSupport to return false (no SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ + Contents: [], + }); + + // Mock legacy file path with content + s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({ + Body: createAsyncIterable(content) as any, + }); + s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({ + Body: createAsyncIterable(hash) as any, + }); +} + +// Mock S3 for SKU version with file content (for redirect endpoints with hash verification) +function mockS3SkuVersionWithContent( + prefix: "app" | "system", + version: string, + sku: string, + fileName: string, + content: string, + hash: string +) { + const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; + + // Mock versionHasSkuSupport to return true (has SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ + Contents: [{ Key: skuPath }], + }); + + // Mock SKU artifact exists with content + s3Mock.on(GetObjectCommand, { Key: skuPath }).resolves({ + Body: createAsyncIterable(content) as any, + }); + + // Mock SKU hash path + s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({ + Body: createAsyncIterable(hash) as any, + }); +} + function findDeviceIdOutsideRollout(threshold: number) { for (let i = 0; i < 10000; i += 1) { const candidate = `device-not-eligible-${i}`; @@ -813,7 +862,7 @@ describe("RetrieveLatestApp handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3FileWithHash("app", "1.2.0", "jetkvm_app", content, hash); + mockS3LegacyVersionWithContent("app", "1.2.0", "jetkvm_app", content, hash); await RetrieveLatestApp(req, res); @@ -836,7 +885,7 @@ describe("RetrieveLatestApp handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3FileWithHash("app", "2.0.0-beta.1", "jetkvm_app", content, hash); + mockS3LegacyVersionWithContent("app", "2.0.0-beta.1", "jetkvm_app", content, hash); await RetrieveLatestApp(req, res); @@ -851,7 +900,7 @@ describe("RetrieveLatestApp handler", () => { CommonPrefixes: [{ Prefix: "app/1.0.0/" }], }); - mockS3FileWithHash("app", "1.0.0", "jetkvm_app", "actual-content", "wrong-hash-value"); + mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", "actual-content", "wrong-hash-value"); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(InternalServerError); }); @@ -864,6 +913,11 @@ describe("RetrieveLatestApp handler", () => { CommonPrefixes: [{ Prefix: "app/1.0.0/" }], }); + // Mock versionHasSkuSupport to return false (no SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: "app/1.0.0/skus/" }).resolves({ + Contents: [], + }); + s3Mock.on(GetObjectCommand, { Key: "app/1.0.0/jetkvm_app" }).resolves({ Body: undefined, }); @@ -873,6 +927,128 @@ describe("RetrieveLatestApp handler", () => { await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); }); + + describe("SKU handling", () => { + it("should use legacy path when no SKU provided on legacy version", async () => { + const req = createMockRequest({}); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/1.0.0/" }], + }); + + const content = "legacy-app-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash); + + await RetrieveLatestApp(req, res); + + expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.0.0/jetkvm_app"); + }); + + it("should use legacy path when default SKU provided on legacy version", async () => { + const req = createMockRequest({ sku: "jetkvm-1" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/1.0.0/" }], + }); + + const content = "legacy-app-content-default-sku"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash); + + await RetrieveLatestApp(req, res); + + expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.0.0/jetkvm_app"); + }); + + it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { + const req = createMockRequest({ sku: "jetkvm-2" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/1.0.0/" }], + }); + + // Mock versionHasSkuSupport to return false (no SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: "app/1.0.0/skus/" }).resolves({ + Contents: [], + }); + + await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); + await expect(RetrieveLatestApp(req, res)).rejects.toThrow("predates SKU support"); + }); + + it("should use SKU path when version has SKU support", async () => { + const req = createMockRequest({ sku: "jetkvm-2" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/2.0.0/" }], + }); + + const content = "sku-app-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-2", "jetkvm_app", content, hash); + + await RetrieveLatestApp(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app" + ); + }); + + it("should use default SKU when no SKU provided on version with SKU support", async () => { + const req = createMockRequest({}); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/2.0.0/" }], + }); + + const content = "default-sku-app-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-1", "jetkvm_app", content, hash); + + await RetrieveLatestApp(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/app/2.0.0/skus/jetkvm-1/jetkvm_app" + ); + }); + + it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { + const req = createMockRequest({ sku: "jetkvm-3" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/2.0.0/" }], + }); + + // Version has SKU support (jetkvm-1 exists) but jetkvm-3 doesn't + s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ + Contents: [{ Key: "app/2.0.0/skus/jetkvm-1/jetkvm_app" }], + }); + s3Mock.on(GetObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); + + await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); + await expect(RetrieveLatestApp(req, res)).rejects.toThrow("is not available for version"); + }); + }); }); describe("RetrieveLatestSystemRecovery handler", () => { @@ -922,7 +1098,7 @@ describe("RetrieveLatestSystemRecovery handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3FileWithHash("system", "1.2.0", "update.img", content, hash); + mockS3LegacyVersionWithContent("system", "1.2.0", "update.img", content, hash); await RetrieveLatestSystemRecovery(req, res); @@ -944,7 +1120,7 @@ describe("RetrieveLatestSystemRecovery handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3FileWithHash("system", "2.0.0-alpha.1", "update.img", content, hash); + mockS3LegacyVersionWithContent("system", "2.0.0-alpha.1", "update.img", content, hash); await RetrieveLatestSystemRecovery(req, res); @@ -962,7 +1138,7 @@ describe("RetrieveLatestSystemRecovery handler", () => { CommonPrefixes: [{ Prefix: "system/1.0.0/" }], }); - mockS3FileWithHash("system", "1.0.0", "update.img", "actual-content", "mismatched-hash"); + mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", "actual-content", "mismatched-hash"); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(InternalServerError); }); @@ -975,6 +1151,11 @@ describe("RetrieveLatestSystemRecovery handler", () => { CommonPrefixes: [{ Prefix: "system/1.0.0/" }], }); + // Mock versionHasSkuSupport to return false (no SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: "system/1.0.0/skus/" }).resolves({ + Contents: [], + }); + s3Mock.on(GetObjectCommand, { Key: "system/1.0.0/update.img" }).resolves({ Body: undefined, }); @@ -984,4 +1165,126 @@ describe("RetrieveLatestSystemRecovery handler", () => { await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); }); + + describe("SKU handling", () => { + it("should use legacy path when no SKU provided on legacy version", async () => { + const req = createMockRequest({}); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/1.0.0/" }], + }); + + const content = "legacy-recovery-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash); + + await RetrieveLatestSystemRecovery(req, res); + + expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.0.0/update.img"); + }); + + it("should use legacy path when default SKU provided on legacy version", async () => { + const req = createMockRequest({ sku: "jetkvm-1" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/1.0.0/" }], + }); + + const content = "legacy-recovery-content-default-sku"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash); + + await RetrieveLatestSystemRecovery(req, res); + + expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.0.0/update.img"); + }); + + it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { + const req = createMockRequest({ sku: "jetkvm-2" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/1.0.0/" }], + }); + + // Mock versionHasSkuSupport to return false (no SKU folders) + s3Mock.on(ListObjectsV2Command, { Prefix: "system/1.0.0/skus/" }).resolves({ + Contents: [], + }); + + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("predates SKU support"); + }); + + it("should use SKU path when version has SKU support", async () => { + const req = createMockRequest({ sku: "jetkvm-2" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + + const content = "sku-recovery-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-2", "update.img", content, hash); + + await RetrieveLatestSystemRecovery(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img" + ); + }); + + it("should use default SKU when no SKU provided on version with SKU support", async () => { + const req = createMockRequest({}); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + + const content = "default-sku-recovery-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-1", "update.img", content, hash); + + await RetrieveLatestSystemRecovery(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/system/2.0.0/skus/jetkvm-1/update.img" + ); + }); + + it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { + const req = createMockRequest({ sku: "jetkvm-3" }); + const res = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + + // Version has SKU support (jetkvm-1 exists) but jetkvm-3 doesn't + s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ + Contents: [{ Key: "system/2.0.0/skus/jetkvm-1/update.img" }], + }); + s3Mock.on(GetObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }).rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); + + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("is not available for version"); + }); + }); }); From 40600565d1d8d62ccd0a5a5a413f6044980b1c77 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:19:52 +0100 Subject: [PATCH 09/18] Remove unused mock function for S3 file with hash and update test to include system URL verification --- test/releases.test.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/test/releases.test.ts b/test/releases.test.ts index 317a4e9..2388dad 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -85,21 +85,6 @@ function mockS3SkuVersion( }); } -// Mock S3 file and hash for redirect endpoints -function mockS3FileWithHash( - prefix: "app" | "system", - version: string, - fileName: string, - content: string, - hash: string -) { - s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({ - Body: createAsyncIterable(content) as any, - }); - s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({ - Body: createAsyncIterable(hash) as any, - }); -} // Mock S3 for legacy version with file content (for redirect endpoints with hash verification) function mockS3LegacyVersionWithContent( @@ -427,6 +412,7 @@ describe("Retrieve handler", () => { expect(res._json.appVersion).toBe("2.0.0"); expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-1/jetkvm_app"); + expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-1/system.tar"); }); it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { From 882a74a914fa2c1d56dfc3a7b0dde9a34e77a1aa Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:37:11 +0100 Subject: [PATCH 10/18] Add instructions to run tests in README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e4379b6..d09cf19 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ npx prisma migrate deploy # Start the production server on port 3000 npm run dev + +# Run tests +npm test ``` ## Production From 73308efea30661a2e8737e7087ba8c0660967a44 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:43:01 +0100 Subject: [PATCH 11/18] Refactor device request handlers to enforce parameter typing for Retrieve, Update, and Delete functions --- src/devices.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/devices.ts b/src/devices.ts index 25d7276..ba42ccf 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -40,7 +40,10 @@ export const List = async (req: express.Request, res: express.Response) => { } }; -export const Retrieve = async (req: express.Request, res: express.Response) => { +export const Retrieve = async ( + req: express.Request<{ id: string }>, + res: express.Response +) => { const idToken = req.session?.id_token; const { sub } = jose.decodeJwt(idToken); const { id } = req.params; @@ -55,7 +58,10 @@ export const Retrieve = async (req: express.Request, res: express.Response) => { return res.status(200).json({ device }); }; -export const Update = async (req: express.Request, res: express.Response) => { +export const Update = async ( + req: express.Request<{ id: string }>, + res: express.Response +) => { const idToken = req.session?.id_token; const { sub } = jose.decodeJwt(idToken); if (!sub) throw new UnauthorizedError("Missing sub in token"); @@ -94,7 +100,10 @@ export const Token = async (req: express.Request, res: express.Response) => { return res.json({ secretToken }); }; -export const Delete = async (req: express.Request, res: express.Response) => { +export const Delete = async ( + req: express.Request<{ id: string }>, + res: express.Response +) => { if (req.headers.authorization?.startsWith("Bearer ")) { const secretToken = req.headers.authorization.split("Bearer ")[1]; From 071615b22d5ec94c0c1fdeb04b04f284445c3296 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:46:31 +0100 Subject: [PATCH 12/18] Update Node.js version in package.json and GitHub Actions workflow to 22.21.0 --- .github/workflows/pull-request.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5a0ae31..63075ee 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -15,7 +15,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: v21.1.0 + node-version: v22.21.0 cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/package.json b/package.json index 1c25eaa..0a60b64 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:coverage": "vitest run --coverage" }, "engines": { - "node": "21.1.0" + "node": "22.21.0" }, "keywords": [], "author": "JetKVM", From 50f6fe3253e4e49f8ee7861603f7c95a2b2e6434 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:51:33 +0100 Subject: [PATCH 13/18] Add PostgreSQL service to GitHub Actions workflow and run Prisma migrations --- .github/workflows/pull-request.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 63075ee..a175e23 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,6 +9,20 @@ on: jobs: build: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: jetkvm + POSTGRES_PASSWORD: jetkvm + POSTGRES_DB: jetkvm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U jetkvm -d jetkvm" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@v4 @@ -35,6 +49,9 @@ jobs: env: CI: true + - name: Run Prisma Migrations + run: npx prisma migrate deploy + - name: Run Tests run: npm test From df05e79db972b39777c0fdf252a62878ec52d721 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 15:53:37 +0100 Subject: [PATCH 14/18] Add DATABASE_URL environment variable to GitHub Actions workflow for PostgreSQL service --- .github/workflows/pull-request.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a175e23..12fe432 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,6 +9,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://jetkvm:jetkvm@localhost:5432/jetkvm?schema=public services: postgres: image: postgres:16 From 724675f6e8ea96041112137bdccf1a3fd669cbee Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 27 Jan 2026 16:05:27 +0100 Subject: [PATCH 15/18] Update default SKU to jetkvm-v2 to align with build config --- src/releases.ts | 2 +- test/releases.test.ts | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index c123145..d4f2901 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -49,7 +49,7 @@ export function clearCaches() { const bucketName = process.env.R2_BUCKET; const baseUrl = process.env.R2_CDN_URL; -const DEFAULT_SKU = "jetkvm-1"; +const DEFAULT_SKU = "jetkvm-v2"; /** * Checks if an object exists in S3/R2 by attempting a GetObjectCommand. diff --git a/test/releases.test.ts b/test/releases.test.ts index 2388dad..030640b 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -337,7 +337,7 @@ describe("Retrieve handler", () => { // Pin versions to bypass rollout; SKU behavior is the only variable here. const req = createMockRequest({ deviceId: "device-123", - sku: "jetkvm-1", + sku: "jetkvm-v2", appVersion: "1.0.0", systemVersion: "1.0.0", }); @@ -405,14 +405,14 @@ describe("Retrieve handler", () => { mockS3ListVersions("app", ["2.0.0"]); mockS3ListVersions("system", ["2.0.0"]); - mockS3SkuVersion("app", "2.0.0", "jetkvm-1", "default-sku-app-hash"); - mockS3SkuVersion("system", "2.0.0", "jetkvm-1", "default-sku-system-hash"); + mockS3SkuVersion("app", "2.0.0", "jetkvm-v2", "default-sku-app-hash"); + mockS3SkuVersion("system", "2.0.0", "jetkvm-v2", "default-sku-system-hash"); await Retrieve(req, res); expect(res._json.appVersion).toBe("2.0.0"); - expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-1/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-1/system.tar"); + expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app"); + expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/system.tar"); }); it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { @@ -427,12 +427,12 @@ describe("Retrieve handler", () => { mockS3ListVersions("app", ["2.0.0"]); mockS3ListVersions("system", ["2.0.0"]); - // Version has SKU support (jetkvm-1 exists) but jetkvm-3 doesn't + // Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ - Contents: [{ Key: "app/2.0.0/skus/jetkvm-1/jetkvm_app" }], + Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }], }); s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ - Contents: [{ Key: "system/2.0.0/skus/jetkvm-1/system.tar" }], + Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/system.tar" }], }); s3Mock.on(GetObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ name: "NoSuchKey", @@ -935,7 +935,7 @@ describe("RetrieveLatestApp handler", () => { }); it("should use legacy path when default SKU provided on legacy version", async () => { - const req = createMockRequest({ sku: "jetkvm-1" }); + const req = createMockRequest({ sku: "jetkvm-v2" }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ @@ -1004,13 +1004,13 @@ describe("RetrieveLatestApp handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-1", "jetkvm_app", content, hash); + mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-v2", "jetkvm_app", content, hash); await RetrieveLatestApp(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/app/2.0.0/skus/jetkvm-1/jetkvm_app" + "https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app" ); }); @@ -1022,9 +1022,9 @@ describe("RetrieveLatestApp handler", () => { CommonPrefixes: [{ Prefix: "app/2.0.0/" }], }); - // Version has SKU support (jetkvm-1 exists) but jetkvm-3 doesn't + // Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ - Contents: [{ Key: "app/2.0.0/skus/jetkvm-1/jetkvm_app" }], + Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }], }); s3Mock.on(GetObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ name: "NoSuchKey", @@ -1173,7 +1173,7 @@ describe("RetrieveLatestSystemRecovery handler", () => { }); it("should use legacy path when default SKU provided on legacy version", async () => { - const req = createMockRequest({ sku: "jetkvm-1" }); + const req = createMockRequest({ sku: "jetkvm-v2" }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ @@ -1242,13 +1242,13 @@ describe("RetrieveLatestSystemRecovery handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-1", "update.img", content, hash); + mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-v2", "update.img", content, hash); await RetrieveLatestSystemRecovery(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/system/2.0.0/skus/jetkvm-1/update.img" + "https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/update.img" ); }); @@ -1260,9 +1260,9 @@ describe("RetrieveLatestSystemRecovery handler", () => { CommonPrefixes: [{ Prefix: "system/2.0.0/" }], }); - // Version has SKU support (jetkvm-1 exists) but jetkvm-3 doesn't + // Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ - Contents: [{ Key: "system/2.0.0/skus/jetkvm-1/update.img" }], + Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/update.img" }], }); s3Mock.on(GetObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }).rejects({ name: "NoSuchKey", From 2a17bde1986f77f481934fe2a9a2acccdb42b021 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Wed, 28 Jan 2026 10:23:52 +0100 Subject: [PATCH 16/18] Add Zod validation for query parameters in release endpoints and update S3 object existence checks to use HeadObjectCommand --- package-lock.json | 14 ++++- package.json | 3 +- src/releases.ts | 141 +++++++++++++++++++++++++----------------- test/releases.test.ts | 19 +++--- 4 files changed, 110 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbd369d..8579101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "semver": "^7.6.3", "ts-node": "^10.9.2", "typescript": "^5.4.5", - "ws": "^8.17.1" + "ws": "^8.17.1", + "zod": "^4.3.6" }, "devDependencies": { "@types/express": "^5.0.6", @@ -41,7 +42,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": "21.1.0" + "node": "22.21.0" }, "optionalDependencies": { "bufferutil": "^4.0.8" @@ -5086,6 +5087,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0a60b64..a0bc976 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "semver": "^7.6.3", "ts-node": "^10.9.2", "typescript": "^5.4.5", - "ws": "^8.17.1" + "ws": "^8.17.1", + "zod": "^4.3.6" }, "optionalDependencies": { "bufferutil": "^4.0.8" diff --git a/src/releases.ts b/src/releases.ts index d4f2901..f878c97 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -3,7 +3,12 @@ import { prisma } from "./db"; import { BadRequestError, InternalServerError, NotFoundError } from "./errors"; import semver from "semver"; -import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; +import { + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; import { LRUCache } from "lru-cache"; import { @@ -12,6 +17,52 @@ import { toSemverRange, verifyHash, } from "./helpers"; +import { z, ZodError } from "zod"; + +/** Query param schema builders for common patterns */ +const queryString = () => z.string().optional().transform(v => v || undefined); +const queryBoolean = () => z.string().optional().transform(v => v === "true"); + +/** + * Schema for redirect endpoints (RetrieveLatestApp, RetrieveLatestSystemRecovery). + * Only needs prerelease flag and optional SKU. + */ +const latestQuerySchema = z.object({ + prerelease: queryBoolean(), + sku: queryString(), +}); + +type LatestQuery = z.infer; + +/** + * Schema for the main Retrieve endpoint. + * Requires deviceId and includes version constraints and forceUpdate flag. + */ +const retrieveQuerySchema = z.object({ + deviceId: z.string({ error: "Device ID is required" }).min(1, "Device ID is required"), + prerelease: queryBoolean(), + appVersion: queryString(), + systemVersion: queryString(), + sku: queryString(), + forceUpdate: queryBoolean(), +}); + +type RetrieveQuery = z.infer; + +/** + * Parses query parameters and converts ZodError to BadRequestError. + */ +function parseQuery(schema: z.ZodSchema, req: Request): T { + try { + return schema.parse(req.query); + } catch (error) { + if (error instanceof ZodError) { + const message = error.issues.map((e: z.ZodIssue) => e.message).join(", "); + throw new BadRequestError(message); + } + throw error; + } +} export interface ReleaseMetadata { version: string; @@ -52,20 +103,21 @@ const baseUrl = process.env.R2_CDN_URL; const DEFAULT_SKU = "jetkvm-v2"; /** - * Checks if an object exists in S3/R2 by attempting a GetObjectCommand. + * Checks if an object exists in S3/R2 by attempting a HeadObjectCommand. * Returns true if the object exists, false otherwise. */ async function s3ObjectExists(key: string): Promise { try { await s3Client.send( - new GetObjectCommand({ + new HeadObjectCommand({ Bucket: bucketName, Key: key, }), ); return true; } catch (error: any) { - if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { + // HeadObjectCommand throws NotFound, but some S3-compatible stores (like R2) may throw NoSuchKey + if (error.name === "NotFound" || error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { return false; } throw error; @@ -296,29 +348,19 @@ async function getDefaultRelease(type: "app" | "system") { } export async function Retrieve(req: Request, res: Response) { - // verify params - const deviceId = req.query.deviceId as string | undefined; - if (!deviceId) { - throw new BadRequestError("Device ID is required"); - } + const query = parseQuery(retrieveQuerySchema, req); - const includePrerelease = req.query.prerelease === "true"; - - const appVersion = toSemverRange(req.query.appVersion as string | undefined); - const systemVersion = toSemverRange(req.query.systemVersion as string | undefined); + const appVersion = toSemverRange(query.appVersion); + const systemVersion = toSemverRange(query.systemVersion); const skipRollout = appVersion !== "*" || systemVersion !== "*"; - // Get SKU from query - undefined means use default with legacy fallback - const skuParam = req.query.sku as string | undefined; - const sku = skuParam === "" ? undefined : skuParam; - // Get the latest release from S3 let remoteRelease: Release; try { - remoteRelease = await getReleaseFromS3(includePrerelease, { + remoteRelease = await getReleaseFromS3(query.prerelease, { appVersion, systemVersion, - sku, + sku: query.sku, }); } catch (error) { console.error(error); @@ -333,7 +375,7 @@ export async function Retrieve(req: Request, res: Response) { // This also prevents us from storing the rollout percentage for prerelease versions // If the version isn't a wildcard, we skip the rollout percentage check - if (includePrerelease || skipRollout) { + if (query.prerelease || skipRollout) { return res.json(remoteRelease); } @@ -370,8 +412,7 @@ 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. */ - const forceUpdate = req.query.forceUpdate === "true"; - if (forceUpdate) { + if (query.forceUpdate) { return res.json(toRelease(latestAppRelease, latestSystemRelease)); } @@ -381,7 +422,7 @@ export async function Retrieve(req: Request, res: Response) { const responseJson = toRelease(defaultAppRelease, defaultSystemRelease); if ( - await isDeviceEligibleForLatestRelease(latestAppRelease.rolloutPercentage, deviceId) + await isDeviceEligibleForLatestRelease(latestAppRelease.rolloutPercentage, query.deviceId) ) { setAppRelease(responseJson, latestAppRelease); } @@ -389,7 +430,7 @@ export async function Retrieve(req: Request, res: Response) { if ( await isDeviceEligibleForLatestRelease( latestSystemRelease.rolloutPercentage, - deviceId, + query.deviceId, ) ) { setSystemRelease(responseJson, latestSystemRelease); @@ -399,33 +440,31 @@ export async function Retrieve(req: Request, res: Response) { } function cachedRedirect( - cachedKey: (req: Request) => string, - callback: (req: Request) => Promise, + cachedKey: (query: LatestQuery) => string, + callback: (query: LatestQuery) => Promise, ) { return async (req: Request, res: Response) => { - const cacheKey = cachedKey(req); + const query = parseQuery(latestQuerySchema, req); + const cacheKey = cachedKey(query); let result = redirectCache.get(cacheKey); if (!result) { - result = await callback(req); + result = await callback(query); redirectCache.set(cacheKey, result); } return res.redirect(302, result); }; } -export const RetrieveLatestSystemRecovery = cachedRedirect( - (req: Request) => { - const skuParam = req.query.sku as string | undefined; - const sku = skuParam === "" ? undefined : skuParam; - return `system-recovery-${req.query.prerelease === "true" ? "pre" : "stable"}-${sku ?? "default"}`; - }, - async (req: Request) => { - const includePrerelease = req.query.prerelease === "true"; - - // Get SKU from query - undefined means use default with legacy fallback - const skuParam = req.query.sku as string | undefined; - const sku = skuParam === "" ? undefined : skuParam; +/** + * Generates a cache key for release endpoints based on prefix, prerelease flag, and SKU. + */ +function releaseCacheKey(prefix: string, query: LatestQuery): string { + return `${prefix}-${query.prerelease ? "pre" : "stable"}-${query.sku ?? "default"}`; +} +export const RetrieveLatestSystemRecovery = cachedRedirect( + (query) => releaseCacheKey("system-recovery", query), + async (query) => { // Get the latest system recovery image from S3. It's stored in the system/ folder. const listCommand = new ListObjectsV2Command({ Bucket: bucketName, @@ -445,7 +484,7 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( .filter(v => semver.valid(v)); const latestVersion = semver.maxSatisfying(versions, "*", { - includePrerelease, + includePrerelease: query.prerelease, }) as string; if (!latestVersion) { @@ -453,7 +492,7 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( } // Resolve the artifact path with SKU support (using update.img for recovery) - const artifactPath = await resolveArtifactPath("system", latestVersion, sku, "update.img"); + const artifactPath = await resolveArtifactPath("system", latestVersion, query.sku, "update.img"); const [firmwareFile, hashFile] = await Promise.all([ // TODO: store file hash using custom header to avoid extra request @@ -486,18 +525,8 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( ); export const RetrieveLatestApp = cachedRedirect( - (req: Request) => { - const skuParam = req.query.sku as string | undefined; - const sku = skuParam === "" ? undefined : skuParam; - return `app-${req.query.prerelease === "true" ? "pre" : "stable"}-${sku ?? "default"}`; - }, - async (req: Request) => { - const includePrerelease = req.query.prerelease === "true"; - - // Get SKU from query - undefined means use default with legacy fallback - const skuParam = req.query.sku as string | undefined; - const sku = skuParam === "" ? undefined : skuParam; - + (query) => releaseCacheKey("app", query), + async (query) => { // Get the latest version const listCommand = new ListObjectsV2Command({ Bucket: bucketName, @@ -515,7 +544,7 @@ export const RetrieveLatestApp = cachedRedirect( ); const latestVersion = semver.maxSatisfying(versions, "*", { - includePrerelease, + includePrerelease: query.prerelease, }) as string; if (!latestVersion) { @@ -523,7 +552,7 @@ export const RetrieveLatestApp = cachedRedirect( } // Resolve the artifact path with SKU support - const artifactPath = await resolveArtifactPath("app", latestVersion, sku); + const artifactPath = await resolveArtifactPath("app", latestVersion, query.sku); // Get the app file and its hash const [appFile, hashFile] = await Promise.all([ diff --git a/test/releases.test.ts b/test/releases.test.ts index 030640b..f253ee0 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Request, Response } from "express"; -import { GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; +import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; import { s3Mock, createAsyncIterable, testPrisma, seedReleases, setRollout, resetToSeedData } from "./setup"; import { BadRequestError, NotFoundError, InternalServerError } from "../src/errors"; @@ -76,8 +76,8 @@ function mockS3SkuVersion( Contents: [{ Key: skuPath }], }); - // Mock SKU artifact exists - s3Mock.on(GetObjectCommand, { Key: skuPath }).resolves({}); + // Mock SKU artifact exists (HeadObjectCommand for existence check) + s3Mock.on(HeadObjectCommand, { Key: skuPath }).resolves({}); // Mock SKU hash path s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({ @@ -124,7 +124,10 @@ function mockS3SkuVersionWithContent( Contents: [{ Key: skuPath }], }); - // Mock SKU artifact exists with content + // Mock SKU artifact exists (HeadObjectCommand for existence check) + s3Mock.on(HeadObjectCommand, { Key: skuPath }).resolves({}); + + // Mock SKU artifact with content (GetObjectCommand for actual fetch) s3Mock.on(GetObjectCommand, { Key: skuPath }).resolves({ Body: createAsyncIterable(content) as any, }); @@ -434,11 +437,11 @@ describe("Retrieve handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/system.tar" }], }); - s3Mock.on(GetObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ + s3Mock.on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ name: "NoSuchKey", $metadata: { httpStatusCode: 404 }, }); - s3Mock.on(GetObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/system.tar" }).rejects({ + s3Mock.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/system.tar" }).rejects({ name: "NoSuchKey", $metadata: { httpStatusCode: 404 }, }); @@ -1026,7 +1029,7 @@ describe("RetrieveLatestApp handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }], }); - s3Mock.on(GetObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ + s3Mock.on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ name: "NoSuchKey", $metadata: { httpStatusCode: 404 }, }); @@ -1264,7 +1267,7 @@ describe("RetrieveLatestSystemRecovery handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/update.img" }], }); - s3Mock.on(GetObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }).rejects({ + s3Mock.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }).rejects({ name: "NoSuchKey", $metadata: { httpStatusCode: 404 }, }); From 39ea9e2dd3b53b677a924f0843eae9392b941bc3 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Wed, 28 Jan 2026 10:47:25 +0100 Subject: [PATCH 17/18] Refactor SKU handling in release endpoints to use a dedicated query schema and ensure consistent defaults --- src/releases.ts | 97 +++++++++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index f878c97..39d590c 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -19,17 +19,32 @@ import { } from "./helpers"; import { z, ZodError } from "zod"; +const DEFAULT_SKU = "jetkvm-v2"; + /** Query param schema builders for common patterns */ -const queryString = () => z.string().optional().transform(v => v || undefined); -const queryBoolean = () => z.string().optional().transform(v => v === "true"); +const queryString = () => + z + .string() + .optional() + .transform(v => v || undefined); +const queryBoolean = () => + z + .string() + .optional() + .transform(v => v === "true"); +const querySku = () => + z + .string() + .optional() + .transform(v => v || DEFAULT_SKU); /** * Schema for redirect endpoints (RetrieveLatestApp, RetrieveLatestSystemRecovery). - * Only needs prerelease flag and optional SKU. + * Only needs prerelease flag and SKU (defaults to jetkvm-v2). */ const latestQuerySchema = z.object({ prerelease: queryBoolean(), - sku: queryString(), + sku: querySku(), }); type LatestQuery = z.infer; @@ -43,7 +58,7 @@ const retrieveQuerySchema = z.object({ prerelease: queryBoolean(), appVersion: queryString(), systemVersion: queryString(), - sku: queryString(), + sku: querySku(), forceUpdate: queryBoolean(), }); @@ -100,24 +115,21 @@ export function clearCaches() { const bucketName = process.env.R2_BUCKET; const baseUrl = process.env.R2_CDN_URL; -const DEFAULT_SKU = "jetkvm-v2"; - /** * Checks if an object exists in S3/R2 by attempting a HeadObjectCommand. * Returns true if the object exists, false otherwise. */ async function s3ObjectExists(key: string): Promise { try { - await s3Client.send( - new HeadObjectCommand({ - Bucket: bucketName, - Key: key, - }), - ); + await s3Client.send(new HeadObjectCommand({ Bucket: bucketName, Key: key })); return true; } catch (error: any) { // HeadObjectCommand throws NotFound, but some S3-compatible stores (like R2) may throw NoSuchKey - if (error.name === "NotFound" || error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { + if ( + error.name === "NotFound" || + error.name === "NoSuchKey" || + error.$metadata?.httpStatusCode === 404 + ) { return false; } throw error; @@ -143,45 +155,46 @@ async function versionHasSkuSupport( } /** - * Resolves the artifact path for a given version and optional SKU. + * Resolves the artifact path for a given version and SKU. * * For versions with SKU support (skus/ folder exists): - * - Uses the provided SKU, or defaults to DEFAULT_SKU + * - Uses the provided SKU * - Fails if the requested SKU is not available * * For legacy versions (no skus/ folder): - * - Returns legacy path for default SKU or when no SKU specified + * - Returns legacy path for default SKU * - Fails for non-default SKUs because legacy firmware predates * that hardware and may not be compatible * * @param prefix - The prefix folder ("app" or "system") * @param version - The version string - * @param sku - Optional SKU identifier + * @param sku - SKU identifier (defaults to jetkvm-v2 from schema) * @param artifactOverride - Optional artifact name override (defaults based on prefix) */ async function resolveArtifactPath( prefix: "app" | "system", version: string, - sku: string | undefined, + sku: string, artifactOverride?: string, ): Promise { const artifact = artifactOverride ?? (prefix === "app" ? "jetkvm_app" : "system.tar"); if (await versionHasSkuSupport(prefix, version)) { - const targetSku = sku ?? DEFAULT_SKU; - const skuPath = `${prefix}/${version}/skus/${targetSku}/${artifact}`; + const skuPath = `${prefix}/${version}/skus/${sku}/${artifact}`; if (await s3ObjectExists(skuPath)) { return skuPath; } - throw new NotFoundError( - `SKU "${targetSku}" is not available for version ${version}`, - ); + throw new NotFoundError(`SKU "${sku}" is not available for version ${version}`); } - // Legacy version - only default SKU (or unspecified) is allowed - if (sku === undefined || sku === DEFAULT_SKU) { + // SKU defaults to "jetkvm-v2" via zod schema when not provided. + // + // For legacy versions (pre-SKU folder structure), we only serve the default SKU. + // This prevents newer hardware variants from rolling back to old firmware + // that may not have compatible binaries for their hardware. + if (sku === DEFAULT_SKU) { return `${prefix}/${version}/${artifact}`; } @@ -194,13 +207,11 @@ async function getLatestVersion( prefix: "app" | "system", includePrerelease: boolean, maxSatisfying: string = "*", - sku?: string, + sku: string, ): Promise { - const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku ?? "default"}`; + const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; const cached = releaseCache.get(cacheKey); - if (cached) { - return cached; - } + if (cached) return cached; const listCommand = new ListObjectsV2Command({ Bucket: bucketName, @@ -303,7 +314,7 @@ async function getReleaseFromS3( appVersion, systemVersion, sku, - }: { appVersion?: string; systemVersion?: string; sku?: string }, + }: { appVersion?: string; systemVersion?: string; sku: string }, ): Promise { const [appRelease, systemRelease] = await Promise.all([ getLatestVersion("app", includePrerelease, appVersion, sku), @@ -422,7 +433,10 @@ export async function Retrieve(req: Request, res: Response) { const responseJson = toRelease(defaultAppRelease, defaultSystemRelease); if ( - await isDeviceEligibleForLatestRelease(latestAppRelease.rolloutPercentage, query.deviceId) + await isDeviceEligibleForLatestRelease( + latestAppRelease.rolloutPercentage, + query.deviceId, + ) ) { setAppRelease(responseJson, latestAppRelease); } @@ -459,12 +473,12 @@ function cachedRedirect( * Generates a cache key for release endpoints based on prefix, prerelease flag, and SKU. */ function releaseCacheKey(prefix: string, query: LatestQuery): string { - return `${prefix}-${query.prerelease ? "pre" : "stable"}-${query.sku ?? "default"}`; + return `${prefix}-${query.prerelease ? "pre" : "stable"}-${query.sku}`; } export const RetrieveLatestSystemRecovery = cachedRedirect( - (query) => releaseCacheKey("system-recovery", query), - async (query) => { + query => releaseCacheKey("system-recovery", query), + async query => { // Get the latest system recovery image from S3. It's stored in the system/ folder. const listCommand = new ListObjectsV2Command({ Bucket: bucketName, @@ -492,7 +506,12 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( } // Resolve the artifact path with SKU support (using update.img for recovery) - const artifactPath = await resolveArtifactPath("system", latestVersion, query.sku, "update.img"); + const artifactPath = await resolveArtifactPath( + "system", + latestVersion, + query.sku, + "update.img", + ); const [firmwareFile, hashFile] = await Promise.all([ // TODO: store file hash using custom header to avoid extra request @@ -525,8 +544,8 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( ); export const RetrieveLatestApp = cachedRedirect( - (query) => releaseCacheKey("app", query), - async (query) => { + query => releaseCacheKey("app", query), + async query => { // Get the latest version const listCommand = new ListObjectsV2Command({ Bucket: bucketName, From 0658b4308adce5a20a49ce497d9518f90eebd041 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Wed, 28 Jan 2026 10:51:19 +0100 Subject: [PATCH 18/18] Add caching behavior tests for RetrieveLatestApp and RetrieveLatestSystemRecovery handlers, ensuring cached redirects and SKU-specific cache keys are correctly implemented --- test/releases.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/test/releases.test.ts b/test/releases.test.ts index f253ee0..9adf0cd 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -1038,6 +1038,72 @@ describe("RetrieveLatestApp handler", () => { await expect(RetrieveLatestApp(req, res)).rejects.toThrow("is not available for version"); }); }); + + describe("cache behavior", () => { + it("should return cached redirect on second call with same parameters", async () => { + const req1 = createMockRequest({}); + const res1 = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/1.0.0/" }], + }); + + const content = "cached-app-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash); + + await RetrieveLatestApp(req1, res1); + expect(res1._redirectUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + + // Reset S3 mock to return different data + s3Mock.reset(); + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/2.0.0/" }], + }); + mockS3LegacyVersionWithContent("app", "2.0.0", "jetkvm_app", "new-content", "new-hash"); + + // Second call should return cached result (1.0.0), not new S3 data (2.0.0) + const req2 = createMockRequest({}); + const res2 = createMockResponse(); + + await RetrieveLatestApp(req2, res2); + expect(res2._redirectUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + }); + + it("should use different cache keys for different SKUs", async () => { + // First call with default SKU + const req1 = createMockRequest({}); + const res1 = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/1.0.0/" }], + }); + + const content = "sku-cache-test"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash); + + await RetrieveLatestApp(req1, res1); + expect(res1._redirectUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); + + // Second call with different SKU should NOT use cached result + s3Mock.reset(); + s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ + CommonPrefixes: [{ Prefix: "app/2.0.0/" }], + }); + mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-2", "jetkvm_app", content, hash); + + const req2 = createMockRequest({ sku: "jetkvm-2" }); + const res2 = createMockResponse(); + + await RetrieveLatestApp(req2, res2); + expect(res2._redirectUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app"); + }); + }); }); describe("RetrieveLatestSystemRecovery handler", () => { @@ -1276,4 +1342,70 @@ describe("RetrieveLatestSystemRecovery handler", () => { await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("is not available for version"); }); }); + + describe("cache behavior", () => { + it("should return cached redirect on second call with same parameters", async () => { + const req1 = createMockRequest({}); + const res1 = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/1.0.0/" }], + }); + + const content = "cached-system-recovery-content"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash); + + await RetrieveLatestSystemRecovery(req1, res1); + expect(res1._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img"); + + // Reset S3 mock to return different data + s3Mock.reset(); + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + mockS3LegacyVersionWithContent("system", "2.0.0", "update.img", "new-content", "new-hash"); + + // Second call should return cached result (1.0.0), not new S3 data (2.0.0) + const req2 = createMockRequest({}); + const res2 = createMockResponse(); + + await RetrieveLatestSystemRecovery(req2, res2); + expect(res2._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img"); + }); + + it("should use different cache keys for different SKUs", async () => { + // First call with default SKU + const req1 = createMockRequest({}); + const res1 = createMockResponse(); + + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/1.0.0/" }], + }); + + const content = "sku-cache-test-recovery"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + + mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash); + + await RetrieveLatestSystemRecovery(req1, res1); + expect(res1._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img"); + + // Second call with different SKU should NOT use cached result + s3Mock.reset(); + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-2", "update.img", content, hash); + + const req2 = createMockRequest({ sku: "jetkvm-2" }); + const res2 = createMockResponse(); + + await RetrieveLatestSystemRecovery(req2, res2); + expect(res2._redirectUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img"); + }); + }); });