From 0d5c0db0258e6e2d269a877dedac8bab3d4df416 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 17:23:53 +0200 Subject: [PATCH 1/8] fix(releases): serialize wire fields only and unify the missing-artifact error (#69) The /releases response carried appCachedAt, appMaxSatisfying and their system counterparts, resolver bookkeeping that no client reads. Drop them from the response type and from ReleaseMetadata, where nothing read them either. Three code paths reported a version with no artifact for the requested SKU with two different messages, one of them claiming the version predates SKU support even for versions that have a skus/ folder. Route all three through one helper with one message. --- src/releases.ts | 41 ++++++++++++++--------------------------- test/releases.test.ts | 16 +++++++++++----- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index fa12821..496bb79 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -96,8 +96,6 @@ export interface ReleaseMetadata { version: string; url: string; hash: string; - _cachedAt?: number; - _maxSatisfying?: string; } interface DbRelease { @@ -145,6 +143,15 @@ export function clearCaches() { const bucketName = process.env.R2_BUCKET; const baseUrl = process.env.R2_CDN_URL; +/** + * The one error for "this version ships no artifact for this SKU", whichever + * path detects it: a skus/ folder without this SKU, a pre-SKU version asked + * for by non-default hardware, or a DB release with no compatible artifact. + */ +function noArtifactForSku(version: string, sku: string): NotFoundError { + return new NotFoundError(`Version ${version} has no artifact for SKU "${sku}"`); +} + /** * Checks if an object exists in S3/R2 by attempting a HeadObjectCommand. * Returns true if the object exists, false otherwise. @@ -216,7 +223,7 @@ async function resolveArtifactPath( return skuPath; } - throw new NotFoundError(`SKU "${sku}" is not available for version ${version}`); + throw noArtifactForSku(version, sku); } // SKU defaults to "jetkvm-v2" via zod schema when not provided. @@ -228,9 +235,7 @@ async function resolveArtifactPath( return `${prefix}/${version}/${artifact}`; } - throw new NotFoundError( - `Version ${version} predates SKU support and cannot serve SKU "${sku}"`, - ); + throw noArtifactForSku(version, sku); } /** @@ -352,43 +357,34 @@ async function getLatestVersion( version: latestVersion, url, hash, - _cachedAt: Date.now(), - _maxSatisfying: maxSatisfying, }; releaseCache.set(cacheKey, release); return release; } +/** Wire shape of the /releases response. Only these fields are serialized. */ interface Release { appVersion: string; appUrl: string; appHash: string; appSigUrl?: string; - appCachedAt?: number; - appMaxSatisfying?: string; systemVersion: string; systemUrl: string; systemHash: string; systemSigUrl?: string; - systemCachedAt?: number; - systemMaxSatisfying?: string; } function setAppRelease(release: Release, appRelease: ReleaseMetadata) { release.appVersion = appRelease.version; release.appUrl = appRelease.url; release.appHash = appRelease.hash; - release.appCachedAt = appRelease._cachedAt; - release.appMaxSatisfying = appRelease._maxSatisfying; } function setSystemRelease(release: Release, systemRelease: ReleaseMetadata) { release.systemVersion = systemRelease.version; release.systemUrl = systemRelease.url; release.systemHash = systemRelease.hash; - release.systemCachedAt = systemRelease._cachedAt; - release.systemMaxSatisfying = systemRelease._maxSatisfying; } function toRelease( @@ -475,23 +471,16 @@ function compatibleReleaseSelect(sku: string) { } as const; } -function dbReleaseToMetadata( - release: DbRelease, - sku: string, - maxSatisfying?: string, -): ReleaseMetadata { +function dbReleaseToMetadata(release: DbRelease, sku: string): ReleaseMetadata { const artifact = release.artifacts[0]; if (!artifact) { - throw new NotFoundError( - `Version ${release.version} predates SKU support and cannot serve SKU "${sku}"`, - ); + throw noArtifactForSku(release.version, sku); } return { version: release.version, url: artifact.url, hash: artifact.hash, - _maxSatisfying: maxSatisfying, }; } @@ -603,12 +592,10 @@ export async function Retrieve(req: Request, res: Response) { dbReleaseToMetadata( await getReleaseByRange("app", query.sku, appVersion), query.sku, - appVersion, ), dbReleaseToMetadata( await getReleaseByRange("system", query.sku, systemVersion), query.sku, - systemVersion, ), ); await addStableSigUrls(responseJson); diff --git a/test/releases.test.ts b/test/releases.test.ts index 62e5111..f0ba7bc 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -399,10 +399,16 @@ describe("Retrieve handler", () => { res, ); - expect(jsonBody(res)).toMatchObject({ + // Exact shape: the response carries the wire fields only, never the + // resolver's internal bookkeeping (cache timestamps, semver ranges). + expect(jsonBody(res)).toEqual({ appVersion: "3.1.0", + appUrl: artifactUrl("app", "3.1.0"), + appHash: "app-3.1.0-jetkvm-v2-hash", appSigUrl: `${artifactUrl("app", "3.1.0")}.sig`, systemVersion: "3.0.0", + systemUrl: artifactUrl("system", "3.0.0"), + systemHash: "system-3.0.0-jetkvm-v2-hash", systemSigUrl: `${artifactUrl("system", "3.0.0")}.sig`, }); }); @@ -873,7 +879,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); - await expect(RetrieveLatestApp(req, res)).rejects.toThrow("predates SKU support"); + await expect(RetrieveLatestApp(req, res)).rejects.toThrow("has no artifact for SKU"); }); it("redirects to the requested SKU path when the S3 version has SKU support", async () => { @@ -955,7 +961,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); await expect(RetrieveLatestApp(req, res)).rejects.toThrow( - "is not available for version", + "has no artifact for SKU", ); }); }); @@ -1233,7 +1239,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - "predates SKU support", + "has no artifact for SKU", ); }); @@ -1338,7 +1344,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - "is not available for version", + "has no artifact for SKU", ); }); }); From 589eee56438c3900df7eb2f547f2cf3e9e095708 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 18:24:23 +0200 Subject: [PATCH 2/8] Releases: one SKU and product registry (#70) releases.ts and sync-releases.ts each carried their own copy of the SKU list, the artifact file names and the recovery-image mapping, and the /releases handler spelled out the app and system artifacts in four places. Add src/skus.ts: one row per SKU listing the artifacts a device with that SKU can get, each as /. The prefix is the R2 folder and the Release.type column. The handlers iterate a SKU's over-the-air artifacts instead of naming app and system, and the two redirects read the prefix and file for the requested SKU from the same table. Unknown SKUs are now rejected at the query schema with a 400. Before, they fell through to a 404 from the artifact lookup, or to the legacy path when the version predated the skus/ layout. On the rollout path the latest and default lookups run sequentially so that, when a prefix has no releases, the 404 from the latest lookup always wins; the parallel form left the 404-versus-500 choice to query timing. Responses for the registered SKUs are unchanged. --- scripts/sync-releases.ts | 35 ++-- src/releases.ts | 412 ++++++++++++++++++------------------- src/skus.ts | 123 +++++++++++ test/releases.test.ts | 72 ++++--- test/setup.ts | 7 +- test/skus.test.ts | 67 ++++++ test/sync-releases.test.ts | 13 +- 7 files changed, 469 insertions(+), 260 deletions(-) create mode 100644 src/skus.ts create mode 100644 test/skus.test.ts diff --git a/scripts/sync-releases.ts b/scripts/sync-releases.ts index 30e965a..c1fa91f 100644 --- a/scripts/sync-releases.ts +++ b/scripts/sync-releases.ts @@ -18,13 +18,12 @@ import { PrismaClient } from "@prisma/client"; import semver from "semver"; import { objectKeyFromArtifactUrl, streamToString } from "../src/helpers"; +import { OTA_PREFIXES, legacyCompatibleSkus, otaFileForPrefix, skusForPrefix } from "../src/skus"; -const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95"; - -type ReleaseType = "app" | "system"; +/** An R2 prefix, which is also the Release.type column value. */ +type ReleaseType = string; -const DEFAULT_SKU = "jetkvm-v2"; -const KNOWN_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; +const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95"; interface SyncClients { prisma: PrismaClient; @@ -43,16 +42,6 @@ interface ReleaseArtifactInput { compatibleSkus: string[]; } -function artifactName(type: ReleaseType): string { - return type === "app" ? "jetkvm_app" : "system.tar"; -} - -// Pre-SKU artifacts (no skus/ folder) are only safe on the original jetkvm-v2. -// Other SKUs require an explicit skus// upload to opt in. -function legacyCompatibleSkus(): string[] { - return [DEFAULT_SKU]; -} - const DEFAULT_ROLLOUT_PERCENTAGE = 10; type ReleaseOutcome = @@ -503,10 +492,18 @@ export async function collectReleaseArtifacts( type: ReleaseType, version: string, ): Promise { - const skus = config.skus ?? KNOWN_SKUS; - const artifactFileName = artifactName(type); + const skus = config.skus ?? skusForPrefix(type); + const artifactFileName = otaFileForPrefix(type); if (!(await versionHasSkuSupport(clients.s3Client, config.bucketName, type, version))) { + // Pre-SKU artifacts (no skus/ folder) are only safe on the SKUs that + // predate the layout. A type with no legacy form treats a version + // without skus/ as an upload mistake, not a release. + const compatibleSkus = legacyCompatibleSkus(type); + if (compatibleSkus.length === 0) { + return []; + } + const artifactPath = `${type}/${version}/${artifactFileName}`; const hash = await readHash(clients.s3Client, config.bucketName, artifactPath); if (!hash) { @@ -517,7 +514,7 @@ export async function collectReleaseArtifacts( { url: `${config.baseUrl}/${artifactPath}`, hash, - compatibleSkus: legacyCompatibleSkus(), + compatibleSkus, }, ]; } @@ -639,7 +636,7 @@ export async function syncReleases( }; let abortedAt: { type: ReleaseType; version: string } | null = null; - outer: for (const type of ["app", "system"] as const) { + outer: for (const type of OTA_PREFIXES) { const versions = await listStableVersions(clients.s3Client, config.bucketName, type); for (const version of versions) { diff --git a/src/releases.ts b/src/releases.ts index 496bb79..7115c06 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -19,21 +19,15 @@ import { verifyHash, } from "./helpers"; import { z, ZodError } from "zod"; - -const DEFAULT_SKU = "jetkvm-v2"; -type ReleaseType = "app" | "system"; - -/** - * Recovery image filename per SKU. eMMC variants are flashed via DFU + the - * Rockchip upgrade tool (RKDevTool format), while SDMMC variants are written - * to a microSD with balenaEtcher (dd-format zip). Add a new SKU here when - * shipping a new hardware variant; unmapped SKUs are rejected so a typo - * doesn't silently fall back to the wrong artifact. - */ -const RECOVERY_ARTIFACT_BY_SKU: Record = { - "jetkvm-v2": "update.img", - "jetkvm-v2-sdmmc": "update_sd.img.zip", -}; +import { + DEFAULT_SKU, + artifactFor, + isKnownSku, + legacyCompatibleSkus, + otaArtifacts, + type OtaKind, + type Artifact, +} from "./skus"; /** Query param schema builders for common patterns */ const queryString = () => @@ -50,7 +44,8 @@ const querySku = () => z .string() .optional() - .transform(v => v || DEFAULT_SKU); + .transform(v => v || DEFAULT_SKU) + .refine(isKnownSku, { error: issue => `Unknown SKU "${issue.input}"` }); /** * Schema for redirect endpoints (RetrieveLatestApp, RetrieveLatestSystemRecovery). @@ -177,10 +172,7 @@ async function s3ObjectExists(key: string): Promise { * 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 { +async function versionHasSkuSupport(prefix: string, version: string): Promise { const response = await s3Client.send( new ListObjectsV2Command({ Bucket: bucketName, @@ -199,25 +191,23 @@ async function versionHasSkuSupport( * - Fails if the requested SKU is not available * * For legacy versions (no skus/ folder): - * - Returns legacy path for default SKU - * - Fails for non-default SKUs because legacy firmware predates + * - Returns legacy path for SKUs that predate the SKU layout + * - Fails for every other SKU because legacy firmware predates * that hardware and may not be compatible * - * @param prefix - The prefix folder ("app" or "system") + * @param prefix - The R2 folder the artifact lives under * @param version - The version string * @param sku - SKU identifier (defaults to jetkvm-v2 from schema) - * @param artifactOverride - Optional artifact name override (defaults based on prefix) + * @param file - The artifact's file name */ async function resolveArtifactPath( - prefix: "app" | "system", + prefix: string, version: string, sku: string, - artifactOverride?: string, + file: string, ): Promise { - const artifact = artifactOverride ?? (prefix === "app" ? "jetkvm_app" : "system.tar"); - if (await versionHasSkuSupport(prefix, version)) { - const skuPath = `${prefix}/${version}/skus/${sku}/${artifact}`; + const skuPath = `${prefix}/${version}/skus/${sku}/${file}`; if (await s3ObjectExists(skuPath)) { return skuPath; @@ -228,11 +218,11 @@ async function resolveArtifactPath( // 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}`; + // For legacy versions (pre-SKU folder structure), we only serve the SKUs + // that predate the layout. This prevents newer hardware variants from + // rolling back to old firmware that may not have compatible binaries. + if (legacyCompatibleSkus(prefix).includes(sku)) { + return `${prefix}/${version}/${file}`; } throw noArtifactForSku(version, sku); @@ -243,16 +233,16 @@ async function resolveArtifactPath( * Results are cached for 5 minutes. */ async function resolveSigUrl( - prefix: "app" | "system", + artifact: Artifact, version: string, sku: string, ): Promise { - const cacheKey = `${prefix}-${version}-${sku}`; + const cacheKey = `${artifact.prefix}-${version}-${sku}`; const cached = sigUrlCache.get(cacheKey); if (cached !== undefined) return cached === MISSING_SIG_URL ? undefined : cached; try { - const path = await resolveArtifactPath(prefix, version, sku); + const path = await resolveArtifactPath(artifact.prefix, version, sku, artifact.file); const sigKey = `${path}.sig`; if (await s3ObjectExists(sigKey)) { const url = `${baseUrl}/${sigKey}`; @@ -278,33 +268,27 @@ async function resolveSigUrl( * Transient S3 errors are logged but don't block the response — sigUrl is optional. */ async function enrichWithSigUrls(release: Release, sku: string): Promise { - const [appSigUrl, systemSigUrl] = await Promise.all([ - release.appVersion - ? resolveSigUrl("app", release.appVersion, sku).catch(e => { - console.error(`Failed to resolve app sig URL for ${release.appVersion}:`, e); - return undefined; - }) - : undefined, - release.systemVersion - ? resolveSigUrl("system", release.systemVersion, sku).catch(e => { - console.error( - `Failed to resolve system sig URL for ${release.systemVersion}:`, - e, - ); - return undefined; - }) - : undefined, - ]); - if (appSigUrl) release.appSigUrl = appSigUrl; - if (systemSigUrl) release.systemSigUrl = systemSigUrl; + await Promise.all( + otaArtifacts(sku).map(async ({ kind, prefix, file }) => { + const version = release[`${kind}Version`]; + if (!version) return; + try { + const sigUrl = await resolveSigUrl({ prefix, file }, version, sku); + if (sigUrl) release[`${kind}SigUrl`] = sigUrl; + } catch (e) { + console.error(`Failed to resolve ${kind} sig URL for ${version}:`, e); + } + }), + ); } async function getLatestVersion( - prefix: "app" | "system", + artifact: Artifact, includePrerelease: boolean, maxSatisfying: string = "*", sku: string, ): Promise { + const { prefix } = artifact; const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; const cached = releaseCache.get(cacheKey); if (cached) return cached; @@ -340,7 +324,7 @@ async function getLatestVersion( ); } - const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku); + const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku, artifact.file); const url = `${baseUrl}/${selectedPath}`; const hashResponse = await s3Client.send( @@ -375,25 +359,24 @@ interface Release { systemSigUrl?: string; } -function setAppRelease(release: Release, appRelease: ReleaseMetadata) { - release.appVersion = appRelease.version; - release.appUrl = appRelease.url; - release.appHash = appRelease.hash; -} +type OtaReleases = Partial>; -function setSystemRelease(release: Release, systemRelease: ReleaseMetadata) { - release.systemVersion = systemRelease.version; - release.systemUrl = systemRelease.url; - release.systemHash = systemRelease.hash; +function setOtaRelease(release: Release, kind: OtaKind, metadata: ReleaseMetadata) { + release[`${kind}Version`] = metadata.version; + release[`${kind}Url`] = metadata.url; + release[`${kind}Hash`] = metadata.hash; } -function toRelease( - appRelease?: ReleaseMetadata, - systemRelease?: ReleaseMetadata, -): Release { +/** + * Builds the response object. Artifacts a product does not ship are left out + * entirely: the device treats an absent one as "not offered". + */ +function toRelease(offers: OtaReleases): Release { const release: Partial = {}; - if (appRelease) setAppRelease(release as Release, appRelease); - if (systemRelease) setSystemRelease(release as Release, systemRelease); + for (const kind of ["app", "system"] as const) { + const metadata = offers[kind]; + if (metadata) setOtaRelease(release as Release, kind, metadata); + } return release as Release; } @@ -430,20 +413,25 @@ async function addStableSigUrls(release: Release): Promise { if (systemSigUrl) release.systemSigUrl = systemSigUrl; } +type VersionConstraints = Record; + async function getReleaseFromS3( includePrerelease: boolean, - { - appVersion, - systemVersion, - sku, - }: { appVersion?: string; systemVersion?: string; sku: string }, + sku: string, + constraints: VersionConstraints, ): Promise { - const [appRelease, systemRelease] = await Promise.all([ - getLatestVersion("app", includePrerelease, appVersion, sku), - getLatestVersion("system", includePrerelease, systemVersion, sku), - ]); - - return toRelease(appRelease, systemRelease); + const offers: OtaReleases = {}; + await Promise.all( + otaArtifacts(sku).map(async ({ kind, prefix, file }) => { + offers[kind] = await getLatestVersion( + { prefix, file }, + includePrerelease, + constraints[kind], + sku, + ); + }), + ); + return toRelease(offers); } async function isDeviceEligibleForLatestRelease( @@ -484,15 +472,15 @@ function dbReleaseToMetadata(release: DbRelease, sku: string): ReleaseMetadata { }; } -async function getDefaultRelease(type: ReleaseType, sku: string): Promise { +async function getDefaultRelease(prefix: string, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ - where: { type, rolloutPercentage: 100 }, + where: { type: prefix, rolloutPercentage: 100 }, select: compatibleReleaseSelect(sku), }); if (rolledOutReleases.length === 0) { throw new InternalServerError( - `No default release found for type ${type} and SKU "${sku}"`, + `No default release found for type ${prefix} and SKU "${sku}"`, ); } @@ -503,7 +491,7 @@ async function getDefaultRelease(type: ReleaseType, sku: string): Promise { - return getReleaseByRange(type, sku, "*"); +async function getLatestRelease(prefix: string, sku: string): Promise { + return getReleaseByRange(prefix, sku, "*"); } async function getReleaseByRange( - type: ReleaseType, + prefix: string, sku: string, range: string, ): Promise { const releases = await prisma.release.findMany({ - where: { type }, + where: { type: prefix }, select: compatibleReleaseSelect(sku), }); if (releases.length === 0) { - throw new NotFoundError(`No release found for type ${type} and SKU "${sku}"`); + throw new NotFoundError(`No release found for type ${prefix} and SKU "${sku}"`); } const latestVersion = semver.maxSatisfying( @@ -547,12 +535,12 @@ async function getReleaseByRange( ) as string; if (!latestVersion) { - throw new NotFoundError(`No ${type} release found that satisfies ${range}`); + throw new NotFoundError(`No ${prefix} release found that satisfies ${range}`); } const latestRelease = releases.find(r => r.version === latestVersion); if (!latestRelease) { - throw new NotFoundError(`No ${type} release found that satisfies ${range}`); + throw new NotFoundError(`No ${prefix} release found that satisfies ${range}`); } return latestRelease; @@ -560,20 +548,20 @@ async function getReleaseByRange( export async function Retrieve(req: Request, res: Response) { const query = parseQuery(retrieveQuerySchema, req); + const { sku, deviceId } = query; - const appVersion = toSemverRange(query.appVersion); - const systemVersion = toSemverRange(query.systemVersion); - const skipRollout = appVersion !== "*" || systemVersion !== "*"; + const artifacts = otaArtifacts(sku); + const constraints: VersionConstraints = { + app: toSemverRange(query.appVersion), + system: toSemverRange(query.systemVersion), + }; + const skipRollout = artifacts.some(({ kind }) => constraints[kind] !== "*"); // Prereleases are not imported into the DB by the stable sync script. if (query.prerelease) { let remoteRelease: Release; try { - remoteRelease = await getReleaseFromS3(query.prerelease, { - appVersion, - systemVersion, - sku: query.sku, - }); + remoteRelease = await getReleaseFromS3(query.prerelease, sku, constraints); } catch (error) { console.error(error); if (error instanceof NotFoundError) { @@ -582,64 +570,47 @@ export async function Retrieve(req: Request, res: Response) { throw new InternalServerError(`Failed to get the latest release from S3: ${error}`); } - await enrichWithSigUrls(remoteRelease, query.sku); + await enrichWithSigUrls(remoteRelease, sku); return res.json(remoteRelease); } // Version-constrained stable requests skip rollout but still read DB metadata. if (skipRollout) { - const responseJson = toRelease( - dbReleaseToMetadata( - await getReleaseByRange("app", query.sku, appVersion), - query.sku, - ), - dbReleaseToMetadata( - await getReleaseByRange("system", query.sku, systemVersion), - query.sku, - ), - ); + const constrained: OtaReleases = {}; + for (const { kind, prefix } of artifacts) { + constrained[kind] = dbReleaseToMetadata( + await getReleaseByRange(prefix, sku, constraints[kind]), + sku, + ); + } + const responseJson = toRelease(constrained); await addStableSigUrls(responseJson); return res.json(responseJson); } - const [latestAppRelease, latestSystemRelease, defaultAppRelease, defaultSystemRelease] = - await Promise.all([ - getLatestRelease("app", query.sku), - getLatestRelease("system", query.sku), - getDefaultRelease("app", query.sku), - getDefaultRelease("system", query.sku), - ]); - // Background update checks follow rollout percentages so new releases roll // out gradually. Devices outside the bucket fall back to the default (the // newest 100%-rolled-out release). If the latest release lacks a compatible // artifact for this SKU (e.g. a SKU-specific build hasn't shipped yet) we // silently keep the default rather than 404 the whole request. - const responseJson = toRelease( - dbReleaseToMetadata(defaultAppRelease, query.sku), - dbReleaseToMetadata(defaultSystemRelease, query.sku), + const offered: OtaReleases = {}; + await Promise.all( + artifacts.map(async ({ kind, prefix }) => { + // Sequential on purpose: when the prefix has no releases at all, the + // 404 from the latest lookup must win over the 500 from the default + // lookup, which the parallel form left to query timing. + const latest = await getLatestRelease(prefix, sku); + const fallback = await getDefaultRelease(prefix, sku); + + const useLatest = + latest.artifacts.length > 0 && + (await isDeviceEligibleForLatestRelease(latest.rolloutPercentage, deviceId)); + + offered[kind] = dbReleaseToMetadata(useLatest ? latest : fallback, sku); + }), ); - if ( - latestAppRelease.artifacts.length > 0 && - (await isDeviceEligibleForLatestRelease( - latestAppRelease.rolloutPercentage, - query.deviceId, - )) - ) { - setAppRelease(responseJson, dbReleaseToMetadata(latestAppRelease, query.sku)); - } - - if ( - latestSystemRelease.artifacts.length > 0 && - (await isDeviceEligibleForLatestRelease( - latestSystemRelease.rolloutPercentage, - query.deviceId, - )) - ) { - setSystemRelease(responseJson, dbReleaseToMetadata(latestSystemRelease, query.sku)); - } - + const responseJson = toRelease(offered); await addStableSigUrls(responseJson); return res.json(responseJson); @@ -668,25 +639,31 @@ function releaseCacheKey(prefix: string, query: LatestQuery): string { return `${prefix}-${query.prerelease ? "pre" : "stable"}-${query.sku}`; } +/** + * 302 to the newest recovery image for the requested SKU. The product table + * says which prefix holds it and what it is called. The route keeps its + * historical name. + */ export const RetrieveLatestSystemRecovery = cachedRedirect( - query => releaseCacheKey("system-recovery", query), + query => releaseCacheKey("recovery", query), async query => { - const recoveryArtifact = RECOVERY_ARTIFACT_BY_SKU[query.sku]; - if (!recoveryArtifact) { - throw new BadRequestError(`Unsupported SKU "${query.sku}"`); + const recovery = artifactFor(query.sku, "recovery"); + if (!recovery) { + throw new BadRequestError( + `SKU "${query.sku}" has no downloadable recovery image`, + ); } - // Get the latest system recovery image from S3. It's stored in the system/ folder. const listCommand = new ListObjectsV2Command({ Bucket: bucketName, - Prefix: "system/", + Prefix: `${recovery.prefix}/`, Delimiter: "/", }); const response = await s3Client.send(listCommand); // Extract version folder names if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError(`No versions found under prefix system recovery image`); + throw new NotFoundError(`No versions found under prefix ${recovery.prefix} for recovery`); } // Get the latest version @@ -699,16 +676,14 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( }) as string; if (!latestVersion) { - throw new NotFoundError("No valid system recovery versions found"); + throw new NotFoundError(`No valid ${recovery.prefix} recovery versions found`); } - // Resolve the artifact path with SKU support; the artifact filename - // depends on the SKU (eMMC = update.img, SDMMC = update_sd.img.zip). const artifactPath = await resolveArtifactPath( - "system", + recovery.prefix, latestVersion, query.sku, - recoveryArtifact, + recovery.file, ); const [firmwareFile, hashFile] = await Promise.all([ @@ -729,71 +704,92 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( if (!firmwareFile.Body || !hashFile.Body) { throw new NotFoundError( - `No system recovery image or hash file not found for version ${latestVersion}`, + `Recovery image or hash file not found for version ${latestVersion}`, ); } - await verifyHash(firmwareFile, hashFile, "system recovery image hash does not match"); + await verifyHash(firmwareFile, hashFile, "recovery image hash does not match"); - console.log("system recovery image hash matches", latestVersion); + console.log("recovery image hash matches", latestVersion); return `${baseUrl}/${artifactPath}`; }, ); -export const RetrieveLatestApp = cachedRedirect( - query => releaseCacheKey("app", query), - async query => { - // Get the latest version - const listCommand = new ListObjectsV2Command({ - Bucket: bucketName, - Prefix: "app/", - Delimiter: "/", - }); - const response = await s3Client.send(listCommand); +/** + * 302 to the newest over-the-air artifact of one kind for the requested SKU, + * after verifying the object against its .sha256 sibling. The product table + * says which prefix holds it, so the same URL serves every product. Used by + * build tooling (rv1106-system pulls the app binary into the system image) + * and by flashing scripts. + */ +function latestArtifactRedirect(kind: OtaKind) { + return cachedRedirect( + query => releaseCacheKey(kind, query), + async query => { + const artifact = artifactFor(query.sku, kind); + if (!artifact) { + throw new BadRequestError(`SKU "${query.sku}" has no ${kind} artifact`); + } + const { prefix } = artifact; - if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError("No app versions found"); - } + const listCommand = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: `${prefix}/`, + Delimiter: "/", + }); + const response = await s3Client.send(listCommand); - const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter( - v => semver.valid(v), - ); + if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { + throw new NotFoundError(`No ${prefix} versions found`); + } - const latestVersion = semver.maxSatisfying(versions, "*", { - includePrerelease: query.prerelease, - }) as string; + const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter( + v => semver.valid(v), + ); - if (!latestVersion) { - throw new NotFoundError("No valid app versions found"); - } + const latestVersion = semver.maxSatisfying(versions, "*", { + includePrerelease: query.prerelease, + }) as string; - // Resolve the artifact path with SKU support - const artifactPath = await resolveArtifactPath("app", latestVersion, query.sku); + if (!latestVersion) { + throw new NotFoundError(`No valid ${prefix} versions found`); + } - // Get the app file and its hash - const [appFile, hashFile] = await Promise.all([ - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: artifactPath, - }), - ), - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: `${artifactPath}.sha256`, - }), - ), - ]); + const artifactPath = await resolveArtifactPath( + prefix, + latestVersion, + query.sku, + artifact.file, + ); - if (!appFile.Body || !hashFile.Body) { - throw new NotFoundError(`App or hash file not found for version ${latestVersion}`); - } + const [artifactFile, hashFile] = await Promise.all([ + s3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: artifactPath, + }), + ), + s3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: `${artifactPath}.sha256`, + }), + ), + ]); + + if (!artifactFile.Body || !hashFile.Body) { + throw new NotFoundError( + `${prefix} artifact or hash file not found for version ${latestVersion}`, + ); + } - await verifyHash(appFile, hashFile, "app hash does not match"); + await verifyHash(artifactFile, hashFile, `${prefix} hash does not match`); - console.log("App hash matches", latestVersion); - return `${baseUrl}/${artifactPath}`; - }, -); + console.log(`${prefix} hash matches`, latestVersion); + return `${baseUrl}/${artifactPath}`; + }, + ); +} + +export const RetrieveLatestApp = latestArtifactRedirect("app"); diff --git a/src/skus.ts b/src/skus.ts new file mode 100644 index 0000000..03c5214 --- /dev/null +++ b/src/skus.ts @@ -0,0 +1,123 @@ +/** + * One row per hardware SKU: the artifacts a device with that SKU can get. + * + * app installed over the air, reported in the app* fields of /releases + * system installed over the air, reported in the system* fields of /releases + * recovery full image flashed by cable, served by /releases/system_recovery/latest + * + * Each value is `/`. The prefix is the R2 folder the artifact + * lives under and the `Release.type` value in the database; artifacts that + * are released together share a prefix and a version number. The full key + * is `//skus//` (+ .sha256, optional .sig). + * + * Keep this table in sync with the firmware build tooling + * (rv1106-system/scripts/common.sh, kvm/Makefile APP_SKUS). Unknown SKUs are + * rejected at the API boundary so a typo can never fall back to firmware + * built for different hardware. + */ + +export type OtaKind = "app" | "system"; +export type ArtifactKind = OtaKind | "recovery"; + +export interface SkuArtifacts { + app?: string; + system?: string; + recovery?: string; + /** + * Uploads made before the skus/ layout existed (`//`, + * no skus/ folder) belong to this SKU. + */ + legacyUploads?: boolean; +} + +export const SKUS: Record = { + // JetKVM (RV1106, Linux). The Go app and the system rootfs are released + // separately, so they have separate prefixes and version numbers. The + // recovery image is built with the rootfs and shares its version. + // eMMC variant: flashed via DFU + the Rockchip upgrade tool (RKDevTool format). + "jetkvm-v2": { + app: "app/jetkvm_app", + system: "system/system.tar", + recovery: "system/update.img", + legacyUploads: true, + }, + // SDMMC variant: written to a microSD with balenaEtcher (dd-format zip). + "jetkvm-v2-sdmmc": { + app: "app/jetkvm_app", + system: "system/system.tar", + recovery: "system/update_sd.img.zip", + }, +}; + +export const DEFAULT_SKU = "jetkvm-v2"; +export const KNOWN_SKUS: readonly string[] = Object.keys(SKUS); +export const OTA_KINDS: readonly OtaKind[] = ["app", "system"]; + +export function isKnownSku(sku: string): boolean { + return Object.prototype.hasOwnProperty.call(SKUS, sku); +} + +export interface Artifact { + prefix: string; + file: string; +} + +export interface OtaArtifact extends Artifact { + kind: OtaKind; +} + +function parseArtifact(value: string): Artifact { + const slash = value.indexOf("/"); + if (slash <= 0 || slash === value.length - 1) { + throw new Error(`Artifact "${value}" must be /`); + } + return { prefix: value.slice(0, slash), file: value.slice(slash + 1) }; +} + +/** The artifact of one kind for a SKU, or undefined when the SKU has none. */ +export function artifactFor(sku: string, kind: ArtifactKind): Artifact | undefined { + const row = SKUS[sku]; + if (!row) { + throw new Error(`Unknown SKU "${sku}"`); + } + const value = row[kind]; + return value ? parseArtifact(value) : undefined; +} + +/** The over-the-air artifacts of a SKU: app, then system, whichever exist. */ +export function otaArtifacts(sku: string): OtaArtifact[] { + return OTA_KINDS.flatMap(kind => { + const artifact = artifactFor(sku, kind); + return artifact ? [{ kind, ...artifact }] : []; + }); +} + +const unique = (values: T[]): T[] => Array.from(new Set(values)); + +const OTA_ROWS = KNOWN_SKUS.flatMap(sku => otaArtifacts(sku).map(artifact => ({ sku, ...artifact }))); + +/** Every prefix that holds over-the-air artifacts, in table order. */ +export const OTA_PREFIXES: readonly string[] = unique(OTA_ROWS.map(row => row.prefix)); + +/** File name of the over-the-air artifact stored under a prefix. */ +export function otaFileForPrefix(prefix: string): string { + const files = unique(OTA_ROWS.filter(row => row.prefix === prefix).map(row => row.file)); + if (files.length !== 1) { + throw new Error(`Prefix "${prefix}" holds ${files.length} over-the-air files, expected one`); + } + return files[0]; +} + +/** SKUs that receive the over-the-air artifact stored under a prefix. */ +export function skusForPrefix(prefix: string): string[] { + return unique(OTA_ROWS.filter(row => row.prefix === prefix).map(row => row.sku)); +} + +/** + * SKUs that may consume a pre-SKU upload (`//` with + * no skus/ folder) under a prefix. Every other SKU requires an explicit + * `skus//` upload. + */ +export function legacyCompatibleSkus(prefix: string): string[] { + return skusForPrefix(prefix).filter(sku => SKUS[sku].legacyUploads); +} diff --git a/test/releases.test.ts b/test/releases.test.ts index f0ba7bc..3675b1c 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -15,10 +15,12 @@ import { RetrieveLatestSystemRecovery, clearCaches, } from "../src/releases"; +import { otaFileForPrefix } from "../src/skus"; + +type ReleaseType = string; const DEFAULT_SKU = "jetkvm-v2"; const SDMMC_SKU = "jetkvm-v2-sdmmc"; -type ReleaseType = "app" | "system"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -55,7 +57,7 @@ function createMockResponse(): Response & { } // Mock S3 responses for listing versions -function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { +function mockS3ListVersions(prefix: ReleaseType, versions: string[]) { s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/` }).resolves({ CommonPrefixes: versions.map(v => ({ Prefix: `${prefix}/${v}/` })), }); @@ -63,12 +65,12 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { // Mock S3 hash file response for legacy versions (no SKU support) function mockS3HashFile( - prefix: "app" | "system", + prefix: ReleaseType, version: string, hash: string, opts?: { hasSig?: boolean }, ) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const fileName = otaFileForPrefix(prefix); const artifactPath = `${prefix}/${version}/${fileName}`; // Mock versionHasSkuSupport to return false (no SKU folders) @@ -89,13 +91,13 @@ function mockS3HashFile( // Mock S3 for versions with SKU support function mockS3SkuVersion( - prefix: "app" | "system", + prefix: ReleaseType, version: string, sku: string, hash: string, opts?: { hasSig?: boolean }, ) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const fileName = otaFileForPrefix(prefix); const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; // Mock versionHasSkuSupport to return true (has SKU folders) @@ -119,7 +121,7 @@ function mockS3SkuVersion( // Mock S3 for legacy version with file content (for redirect endpoints with hash verification) function mockS3LegacyVersionWithContent( - prefix: "app" | "system", + prefix: ReleaseType, version: string, fileName: string, content: string, @@ -143,7 +145,7 @@ function mockS3LegacyVersionWithContent( // Mock S3 for SKU version with file content (for redirect endpoints with hash verification) function mockS3SkuVersionWithContent( - prefix: "app" | "system", + prefix: ReleaseType, version: string, sku: string, fileName: string, @@ -172,7 +174,7 @@ function mockS3SkuVersionWithContent( } function artifactFileName(type: ReleaseType) { - return type === "app" ? "jetkvm_app" : "system.tar"; + return otaFileForPrefix(type); } function artifactPath(type: ReleaseType, version: string, sku = DEFAULT_SKU) { @@ -263,6 +265,26 @@ describe("Retrieve handler", () => { }); }); + describe("SKU validation", () => { + it("rejects a SKU that is not registered before touching S3 or the DB", async () => { + const res = createMockResponse(); + await expect( + Retrieve(createMockRequest({ deviceId: "x", sku: "jetkvm-v3" }), res), + ).rejects.toThrow(BadRequestError); + await expect( + Retrieve(createMockRequest({ deviceId: "x", sku: "jetkvm-v3" }), res), + ).rejects.toThrow('Unknown SKU "jetkvm-v3"'); + expect(s3Mock.calls().length).toBe(0); + }); + + it("rejects an unregistered SKU on the app redirect", async () => { + const res = createMockResponse(); + await expect(RetrieveLatestApp(createMockRequest({ sku: "jetkvm-v3" }), res)).rejects.toThrow( + 'Unknown SKU "jetkvm-v3"', + ); + }); + }); + describe("S3 error handling", () => { it("should throw NotFoundError when no versions exist in S3", async () => { const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); @@ -619,7 +641,7 @@ describe("Retrieve handler", () => { const req = createMockRequest({ deviceId: "device-sku-sig", prerelease: "true", - sku: "jetkvm-2", + sku: SDMMC_SKU, appVersion: "^8.0.0", systemVersion: "^8.0.0", }); @@ -627,18 +649,18 @@ describe("Retrieve handler", () => { mockS3ListVersions("app", ["8.0.0"]); mockS3ListVersions("system", ["8.0.0"]); - mockS3SkuVersion("app", "8.0.0", "jetkvm-2", "sku-sig-app-hash", { hasSig: true }); - mockS3SkuVersion("system", "8.0.0", "jetkvm-2", "sku-sig-system-hash", { + mockS3SkuVersion("app", "8.0.0", SDMMC_SKU, "sku-sig-app-hash", { hasSig: true }); + mockS3SkuVersion("system", "8.0.0", SDMMC_SKU, "sku-sig-system-hash", { hasSig: true, }); await Retrieve(req, res); expect(res._json.appSigUrl).toBe( - "https://cdn.test.com/app/8.0.0/skus/jetkvm-2/jetkvm_app.sig", + "https://cdn.test.com/app/8.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app.sig", ); expect(res._json.systemSigUrl).toBe( - "https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig", + "https://cdn.test.com/system/8.0.0/skus/jetkvm-v2-sdmmc/system.tar.sig", ); }); }); @@ -866,7 +888,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { - const req = createMockRequest({ sku: "jetkvm-2" }); + const req = createMockRequest({ sku: SDMMC_SKU }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ @@ -883,7 +905,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); it("redirects to the requested SKU path when the S3 version has SKU support", async () => { - const req = createMockRequest({ sku: "jetkvm-2" }); + const req = createMockRequest({ sku: SDMMC_SKU }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ @@ -897,7 +919,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { mockS3SkuVersionWithContent( "app", "2.0.0", - "jetkvm-2", + SDMMC_SKU, "jetkvm_app", content, hash, @@ -907,7 +929,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app", + "https://cdn.test.com/app/2.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app", ); }); @@ -941,19 +963,19 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { - const req = createMockRequest({ sku: "jetkvm-3" }); + const req = createMockRequest({ sku: SDMMC_SKU }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ CommonPrefixes: [{ Prefix: "app/2.0.0/" }], }); - // Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't + // Version has SKU support (jetkvm-v2 exists) but the SDMMC SKU doesn't s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }], }); s3Mock - .on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }) + .on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app" }) .rejects({ name: "NoSuchKey", $metadata: { httpStatusCode: 404 }, @@ -1031,18 +1053,18 @@ describe("RetrieveLatestApp S3 redirect handler", () => { mockS3SkuVersionWithContent( "app", "2.0.0", - "jetkvm-2", + SDMMC_SKU, "jetkvm_app", content, hash, ); - const req2 = createMockRequest({ sku: "jetkvm-2" }); + const req2 = createMockRequest({ sku: SDMMC_SKU }); const res2 = createMockResponse(); await RetrieveLatestApp(req2, res2); expect(res2._redirectUrl).toBe( - "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app", + "https://cdn.test.com/app/2.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app", ); }); }); @@ -1287,7 +1309,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { BadRequestError, ); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - 'Unsupported SKU "jetkvm-future"', + 'Unknown SKU "jetkvm-future"', ); }); diff --git a/test/setup.ts b/test/setup.ts index 8f95d77..8769ab2 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -3,6 +3,9 @@ import { mockClient } from "aws-sdk-client-mock"; import { S3Client } from "@aws-sdk/client-s3"; import { PrismaClient } from "@prisma/client"; import { config } from "dotenv"; +import { otaFileForPrefix } from "../src/skus"; + +type ReleaseType = string; // Load .env.development for config config({ path: ".env.development" }); @@ -24,8 +27,6 @@ export const s3Mock = mockClient(S3Client); // Create a test Prisma client export const testPrisma = new PrismaClient(); -type ReleaseType = "app" | "system"; - // Pre-SKU artifacts are jetkvm-v2 only; future SKUs need explicit // skus// uploads, registered via scripts/sync-releases.ts. const LEGACY_COMPATIBLE_SKUS = ["jetkvm-v2"]; @@ -141,7 +142,7 @@ export async function setRollout( version, type, rolloutPercentage: percentage, - url: `https://cdn.test.com/${type}/${version}/${type === "app" ? "jetkvm_app" : "system.tar"}`, + url: `https://cdn.test.com/${type}/${version}/${otaFileForPrefix(type)}`, hash: `test-hash-${version}-${type}`, }, }); diff --git a/test/skus.test.ts b/test/skus.test.ts new file mode 100644 index 0000000..c2a2f38 --- /dev/null +++ b/test/skus.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_SKU, + KNOWN_SKUS, + OTA_PREFIXES, + artifactFor, + isKnownSku, + legacyCompatibleSkus, + otaArtifacts, + otaFileForPrefix, + skusForPrefix, +} from "../src/skus"; + +const JETKVM_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; + +describe("SKU table", () => { + it("registers the eMMC and SDMMC JetKVM variants, eMMC first as the default", () => { + expect(KNOWN_SKUS).toEqual(JETKVM_SKUS); + expect(KNOWN_SKUS[0]).toBe(DEFAULT_SKU); + }); + + it("gives both JetKVM variants the same two over-the-air artifacts", () => { + for (const sku of JETKVM_SKUS) { + expect(otaArtifacts(sku)).toEqual([ + { kind: "app", prefix: "app", file: "jetkvm_app" }, + { kind: "system", prefix: "system", file: "system.tar" }, + ]); + } + }); + + it("keeps the recovery image with the system artifact, named per variant", () => { + expect(artifactFor("jetkvm-v2", "recovery")).toEqual({ + prefix: "system", + file: "update.img", + }); + expect(artifactFor("jetkvm-v2-sdmmc", "recovery")).toEqual({ + prefix: "system", + file: "update_sd.img.zip", + }); + }); + + it("syncs the app and system prefixes, each holding one over-the-air file", () => { + expect(OTA_PREFIXES).toEqual(["app", "system"]); + expect(otaFileForPrefix("app")).toBe("jetkvm_app"); + expect(otaFileForPrefix("system")).toBe("system.tar"); + expect(() => otaFileForPrefix("nope")).toThrow('Prefix "nope" holds 0'); + }); + + it("lists every JetKVM variant as a consumer of both prefixes", () => { + expect(skusForPrefix("app")).toEqual(JETKVM_SKUS); + expect(skusForPrefix("system")).toEqual(JETKVM_SKUS); + }); + + it("only allows the original hardware on the pre-SKU layout", () => { + expect(legacyCompatibleSkus("app")).toEqual([DEFAULT_SKU]); + expect(legacyCompatibleSkus("system")).toEqual([DEFAULT_SKU]); + expect(legacyCompatibleSkus("nope")).toEqual([]); + }); + + it("rejects SKUs that are not registered, including prototype keys", () => { + expect(isKnownSku("jetkvm-v3")).toBe(false); + expect(isKnownSku("__proto__")).toBe(false); + expect(isKnownSku("constructor")).toBe(false); + expect(() => artifactFor("jetkvm-v3", "app")).toThrow('Unknown SKU "jetkvm-v3"'); + }); +}); diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 68267e1..0bbccaf 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -7,6 +7,9 @@ import { import { describe, expect, beforeEach, it } from "vitest"; import { collectReleaseArtifacts, syncReleases } from "../scripts/sync-releases"; +import { otaFileForPrefix } from "../src/skus"; + +type ReleaseType = string; import { createAsyncIterable, s3Mock, testPrisma } from "./setup"; const DEFAULT_SKU = "jetkvm-v2"; @@ -15,14 +18,14 @@ const SYNC_BUCKET = "test-bucket"; const SYNC_BASE_URL = "https://cdn.test.com"; const syncS3Client = new S3Client({}); -function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { +function mockS3ListVersions(prefix: ReleaseType, versions: string[]) { s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/` }).resolves({ CommonPrefixes: versions.map(v => ({ Prefix: `${prefix}/${v}/` })), }); } -function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; +function mockS3HashFile(prefix: ReleaseType, version: string, hash: string) { + const fileName = otaFileForPrefix(prefix); s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ Contents: [], }); @@ -34,12 +37,12 @@ function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) } function mockS3SkuVersion( - prefix: "app" | "system", + prefix: ReleaseType, version: string, sku: string, hash: string, ) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const fileName = otaFileForPrefix(prefix); const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ From bae1cc0dddaa856ade0e3767c83dc203822902bb Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 18:25:05 +0200 Subject: [PATCH 3/8] Compare script: follow the #69 response contract (#74) The local-versus-production comparison still expected the fields and messages #69 removed: - normalize_body dropped only *CachedAt keys, so any constrained stable request reported the removed *MaxSatisfying fields as a body mismatch and, with FAIL_FAST, aborted the run. Drop *MaxSatisfying too. - body_diff_is_version_only_not_found canonicalized the two older no-compatible-release messages only. Add the unified message, the S3 path wording production still emits, and the mini prefix in the no-default pattern. Both are comparison-only changes; the API is untouched. --- scripts/compare-releases.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh index 8bab1e6..8297bc1 100755 --- a/scripts/compare-releases.sh +++ b/scripts/compare-releases.sh @@ -160,12 +160,16 @@ try: except Exception: normalized_path.write_text(body) else: + # Resolver bookkeeping that older deploys still serialize; the device + # never reads it, so it is not part of the comparison. + ignored_suffixes = ("CachedAt", "MaxSatisfying") + def scrub(value): if isinstance(value, dict): return { key: scrub(child) for key, child in value.items() - if not key.endswith("CachedAt") + if not key.endswith(ignored_suffixes) } if isinstance(value, list): return [scrub(item) for item in value] @@ -261,8 +265,10 @@ if left_keys != {"name", "message"} or right_keys != {"name", "message"}: # Both messages mean "no release compatible with this SKU is available"; # the wording differs across deploys but the device sees the same 404. no_compat_patterns = [ + re.compile(r'^Version .+ has no artifact for SKU "([^"]+)"$'), re.compile(r'^Version .+ predates SKU support and cannot serve SKU "([^"]+)"$'), - re.compile(r'^No default (?:app|system) release available for SKU "([^"]+)"$'), + re.compile(r'^SKU "([^"]+)" is not available for version .+$'), + re.compile(r'^No default (?:app|system|mini) release available for SKU "([^"]+)"$'), ] def canonicalize(message): From ad58cc909bd36e17cd2ddbe4c2322335eb09240a Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 18:25:30 +0200 Subject: [PATCH 4/8] Releases: JetKVM Mini as a single-component product (#71) The Mini (ESP32-P4) ships one firmware image, and that image is the whole system: FreeRTOS, drivers, the application, the web UI and the Wi-Fi co-processor firmware. There is no separate app. Register it as a product with one over-the-air artifact, system, served in the system fields of the /releases response from the new prefix mini//skus//jetkvm-mini.bin. The app fields are omitted. Two variants, one per board, because the boards are separate builds: jetkvm-mini-ethernet and jetkvm-mini-wireless. Its own prefix, not Mini variants under system/, so Mini version numbers never enter the JetKVM latest-release selection and staged rollout, and the Mini rolls out on its own schedule. A mini version without the skus/ layout is not a release and is skipped by the sync script. /releases/app/latest rejects Mini variants: no app artifact. The recovery redirect rejects them until a full-flash image exists. --- prisma/schema.prisma | 2 +- src/skus.ts | 16 +++- test/releases.test.ts | 163 ++++++++++++++++++++++++++++++++++++- test/skus.test.ts | 38 ++++++--- test/sync-releases.test.ts | 43 ++++++++++ 5 files changed, 245 insertions(+), 17 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 080a46a..493b1cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,7 +49,7 @@ model Release { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt url String - type String @default("app") // "app" or "system" + type String @default("app") // R2 prefix: "app", "system" or "mini" (see src/skus.ts) hash String artifacts ReleaseArtifact[] diff --git a/src/skus.ts b/src/skus.ts index 03c5214..1f8c494 100644 --- a/src/skus.ts +++ b/src/skus.ts @@ -11,9 +11,9 @@ * is `//skus//` (+ .sha256, optional .sig). * * Keep this table in sync with the firmware build tooling - * (rv1106-system/scripts/common.sh, kvm/Makefile APP_SKUS). Unknown SKUs are - * rejected at the API boundary so a typo can never fall back to firmware - * built for different hardware. + * (rv1106-system/scripts/common.sh, kvm/Makefile APP_SKUS, the kvm-mini + * release script). Unknown SKUs are rejected at the API boundary so a typo + * can never fall back to firmware built for different hardware. */ export type OtaKind = "app" | "system"; @@ -47,6 +47,16 @@ export const SKUS: Record = { system: "system/system.tar", recovery: "system/update_sd.img.zip", }, + // JetKVM Mini (ESP32-P4). One firmware image is the whole system: + // FreeRTOS, drivers, the application, the web UI and the ESP32-C5 Wi-Fi + // co-processor firmware. There is no separate app. The Ethernet (IP101) + // and wireless (ESP32-C5 over SDIO) boards are separate builds. + "jetkvm-mini-ethernet": { + system: "mini/jetkvm-mini.bin", + }, + "jetkvm-mini-wireless": { + system: "mini/jetkvm-mini.bin", + }, }; export const DEFAULT_SKU = "jetkvm-v2"; diff --git a/test/releases.test.ts b/test/releases.test.ts index 3675b1c..c3c7174 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -15,12 +15,14 @@ import { RetrieveLatestSystemRecovery, clearCaches, } from "../src/releases"; -import { otaFileForPrefix } from "../src/skus"; +import { legacyCompatibleSkus, otaFileForPrefix } from "../src/skus"; type ReleaseType = string; const DEFAULT_SKU = "jetkvm-v2"; const SDMMC_SKU = "jetkvm-v2-sdmmc"; +const MINI_ETHERNET_SKU = "jetkvm-mini-ethernet"; +const MINI_WIRELESS_SKU = "jetkvm-mini-wireless"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -179,7 +181,7 @@ function artifactFileName(type: ReleaseType) { function artifactPath(type: ReleaseType, version: string, sku = DEFAULT_SKU) { const fileName = artifactFileName(type); - if (sku === DEFAULT_SKU) { + if (legacyCompatibleSkus(type).includes(sku)) { return `${type}/${version}/${fileName}`; } return `${type}/${version}/skus/${sku}/${fileName}`; @@ -665,6 +667,133 @@ describe("Retrieve handler", () => { }); }); + describe("mini product (one over-the-air artifact)", () => { + function miniArtifacts(version: string) { + return [ + releaseArtifact("mini", version, MINI_ETHERNET_SKU), + releaseArtifact("mini", version, MINI_WIRELESS_SKU), + ]; + } + + it("serves the mini firmware as the system artifact and omits the app fields", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res); + + expect(jsonBody(res)).toEqual({ + systemVersion: "1.0.0", + systemUrl: artifactUrl("mini", "1.0.0", MINI_ETHERNET_SKU), + systemHash: "mini-1.0.0-jetkvm-mini-ethernet-hash", + }); + }); + + it("selects the artifact for the wireless variant", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-wireless", sku: MINI_WIRELESS_SKU }), res); + + expect(jsonBody(res)).toMatchObject({ + systemUrl: artifactUrl("mini", "1.0.0", MINI_WIRELESS_SKU), + systemHash: "mini-1.0.0-jetkvm-mini-wireless-hash", + }); + }); + + it("ignores JetKVM app/system releases and honours mini rollout state", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + await createDbRelease("mini", "1.1.0", 0, miniArtifacts("1.1.0")); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res); + + // Seed data has app/system 1.2.0 in rollout; none of it leaks into the + // mini offer, and the 0% mini release is held back. + expect(jsonBody(res)).toMatchObject({ systemVersion: "1.0.0" }); + expect(jsonBody(res)).not.toHaveProperty("appVersion"); + }); + + it("honours a systemVersion constraint and ignores appVersion", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + await createDbRelease("mini", "1.1.0", 0, miniArtifacts("1.1.0")); + + const res = createMockResponse(); + await Retrieve( + createMockRequest({ + deviceId: "mini-ethernet", + sku: MINI_ETHERNET_SKU, + systemVersion: "1.1.0", + appVersion: "9.9.9", + }), + res, + ); + + expect(jsonBody(res)).toEqual({ + systemVersion: "1.1.0", + systemUrl: artifactUrl("mini", "1.1.0", MINI_ETHERNET_SKU), + systemHash: "mini-1.1.0-jetkvm-mini-ethernet-hash", + }); + }); + + it("includes systemSigUrl from the mini artifact's .sig sibling", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + mockArtifactSig("mini", "1.0.0", MINI_ETHERNET_SKU); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res); + + expect(jsonBody(res)).toMatchObject({ + systemSigUrl: `${artifactUrl("mini", "1.0.0", MINI_ETHERNET_SKU)}.sig`, + }); + expect(jsonBody(res)).not.toHaveProperty("appSigUrl"); + }); + + it("reads prereleases from the mini/ prefix only", async () => { + mockS3ListVersions("mini", ["1.0.0", "1.1.0-beta.1"]); + mockS3SkuVersion("mini", "1.1.0-beta.1", MINI_ETHERNET_SKU, "mini-beta-hash", { + hasSig: true, + }); + + const res = createMockResponse(); + await Retrieve( + createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU, prerelease: "true" }), + res, + ); + + expect(jsonBody(res)).toEqual({ + systemVersion: "1.1.0-beta.1", + systemUrl: `https://cdn.test.com/mini/1.1.0-beta.1/skus/${MINI_ETHERNET_SKU}/jetkvm-mini.bin`, + systemHash: "mini-beta-hash", + systemSigUrl: `https://cdn.test.com/mini/1.1.0-beta.1/skus/${MINI_ETHERNET_SKU}/jetkvm-mini.bin.sig`, + }); + // The app/ and system/ prefixes were never listed. + expect( + s3Mock.commandCalls(ListObjectsV2Command, { Prefix: "app/" }).length + + s3Mock.commandCalls(ListObjectsV2Command, { Prefix: "system/" }).length, + ).toBe(0); + }); + + it("never serves a pre-SKU mini layout", async () => { + mockS3ListVersions("mini", ["1.0.0"]); + mockS3HashFile("mini", "1.0.0", "legacy-mini-hash"); + + const res = createMockResponse(); + await expect( + Retrieve( + createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU, prerelease: "true" }), + res, + ), + ).rejects.toThrow('Version 1.0.0 has no artifact for SKU "jetkvm-mini-ethernet"'); + }); + + it("fails when no mini release exists for the SKU", async () => { + const res = createMockResponse(); + await expect( + Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res), + ).rejects.toThrow(NotFoundError); + }); + }); + describe("S3 non-NotFoundError handling", () => { it("should wrap non-NotFoundError in InternalServerError", async () => { const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); @@ -1070,6 +1199,24 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); }); +describe("RetrieveLatestApp for the mini product", () => { + beforeEach(() => { + clearCaches(); + s3Mock.reset(); + }); + + it("rejects mini SKUs, which have no app artifact, before touching S3", async () => { + const res = createMockResponse(); + await expect( + RetrieveLatestApp(createMockRequest({ sku: MINI_WIRELESS_SKU }), res), + ).rejects.toThrow(BadRequestError); + await expect( + RetrieveLatestApp(createMockRequest({ sku: MINI_WIRELESS_SKU }), res), + ).rejects.toThrow('SKU "jetkvm-mini-wireless" has no app artifact'); + expect(s3Mock.calls().length).toBe(0); + }); +}); + describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { beforeEach(() => { s3Mock.reset(); @@ -1313,6 +1460,18 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { ); }); + it("should throw BadRequestError for a SKU without a recovery image", async () => { + const req = createMockRequest({ sku: MINI_ETHERNET_SKU }); + const res = createMockResponse(); + + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( + BadRequestError, + ); + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( + 'SKU "jetkvm-mini-ethernet" has no downloadable recovery image', + ); + }); + it("should use default SKU when no SKU provided on version with SKU support", async () => { const req = createMockRequest({}); const res = createMockResponse(); diff --git a/test/skus.test.ts b/test/skus.test.ts index c2a2f38..e61f208 100644 --- a/test/skus.test.ts +++ b/test/skus.test.ts @@ -13,10 +13,11 @@ import { } from "../src/skus"; const JETKVM_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; +const MINI_SKUS = ["jetkvm-mini-ethernet", "jetkvm-mini-wireless"]; describe("SKU table", () => { - it("registers the eMMC and SDMMC JetKVM variants, eMMC first as the default", () => { - expect(KNOWN_SKUS).toEqual(JETKVM_SKUS); + it("registers both JetKVM variants first, then both Mini variants", () => { + expect(KNOWN_SKUS).toEqual([...JETKVM_SKUS, ...MINI_SKUS]); expect(KNOWN_SKUS[0]).toBe(DEFAULT_SKU); }); @@ -29,7 +30,16 @@ describe("SKU table", () => { } }); - it("keeps the recovery image with the system artifact, named per variant", () => { + it("gives both Mini variants one over-the-air artifact, the firmware image as system", () => { + for (const sku of MINI_SKUS) { + expect(otaArtifacts(sku)).toEqual([ + { kind: "system", prefix: "mini", file: "jetkvm-mini.bin" }, + ]); + expect(artifactFor(sku, "app")).toBeUndefined(); + } + }); + + it("keeps the JetKVM recovery image with the system artifact and gives the Mini none", () => { expect(artifactFor("jetkvm-v2", "recovery")).toEqual({ prefix: "system", file: "update.img", @@ -38,30 +48,36 @@ describe("SKU table", () => { prefix: "system", file: "update_sd.img.zip", }); + for (const sku of MINI_SKUS) { + expect(artifactFor(sku, "recovery")).toBeUndefined(); + } }); - it("syncs the app and system prefixes, each holding one over-the-air file", () => { - expect(OTA_PREFIXES).toEqual(["app", "system"]); + it("syncs three prefixes, each holding one over-the-air file", () => { + expect(OTA_PREFIXES).toEqual(["app", "system", "mini"]); expect(otaFileForPrefix("app")).toBe("jetkvm_app"); expect(otaFileForPrefix("system")).toBe("system.tar"); - expect(() => otaFileForPrefix("nope")).toThrow('Prefix "nope" holds 0'); + expect(otaFileForPrefix("mini")).toBe("jetkvm-mini.bin"); }); - it("lists every JetKVM variant as a consumer of both prefixes", () => { + it("lists the variants that consume each prefix", () => { expect(skusForPrefix("app")).toEqual(JETKVM_SKUS); expect(skusForPrefix("system")).toEqual(JETKVM_SKUS); + expect(skusForPrefix("mini")).toEqual(MINI_SKUS); }); - it("only allows the original hardware on the pre-SKU layout", () => { + it("only allows the original hardware on the pre-SKU layout, and no mini at all", () => { expect(legacyCompatibleSkus("app")).toEqual([DEFAULT_SKU]); expect(legacyCompatibleSkus("system")).toEqual([DEFAULT_SKU]); - expect(legacyCompatibleSkus("nope")).toEqual([]); + expect(legacyCompatibleSkus("mini")).toEqual([]); }); it("rejects SKUs that are not registered, including prototype keys", () => { - expect(isKnownSku("jetkvm-v3")).toBe(false); + expect(isKnownSku("jetkvm-mini-lte")).toBe(false); expect(isKnownSku("__proto__")).toBe(false); expect(isKnownSku("constructor")).toBe(false); - expect(() => artifactFor("jetkvm-v3", "app")).toThrow('Unknown SKU "jetkvm-v3"'); + expect(() => artifactFor("jetkvm-mini-lte", "system")).toThrow( + 'Unknown SKU "jetkvm-mini-lte"', + ); }); }); diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 0bbccaf..c0f1a92 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -14,6 +14,8 @@ import { createAsyncIterable, s3Mock, testPrisma } from "./setup"; const DEFAULT_SKU = "jetkvm-v2"; const SDMMC_SKU = "jetkvm-v2-sdmmc"; +const MINI_ETHERNET_SKU = "jetkvm-mini-ethernet"; +const MINI_WIRELESS_SKU = "jetkvm-mini-wireless"; const SYNC_BUCKET = "test-bucket"; const SYNC_BASE_URL = "https://cdn.test.com"; const syncS3Client = new S3Client({}); @@ -119,6 +121,46 @@ describe("sync-releases script", () => { ]); }); + it("collects mini artifacts for the mini SKUs only", async () => { + mockS3SkuVersion("mini", "1.0.0", MINI_ETHERNET_SKU, "mini-ethernet-hash"); + mockS3SkuVersion("mini", "1.0.0", MINI_WIRELESS_SKU, "mini-wireless-hash"); + // A stray JetKVM upload under mini/ must not be picked up. + mockS3SkuVersion("mini", "1.0.0", DEFAULT_SKU, "stray-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "mini", + "1.0.0", + ); + + expect(artifacts).toEqual([ + { + url: `https://cdn.test.com/mini/1.0.0/skus/${MINI_ETHERNET_SKU}/jetkvm-mini.bin`, + hash: "mini-ethernet-hash", + compatibleSkus: [MINI_ETHERNET_SKU], + }, + { + url: `https://cdn.test.com/mini/1.0.0/skus/${MINI_WIRELESS_SKU}/jetkvm-mini.bin`, + hash: "mini-wireless-hash", + compatibleSkus: [MINI_WIRELESS_SKU], + }, + ]); + }); + + it("ignores a mini version uploaded without the skus/ layout", async () => { + mockS3HashFile("mini", "1.0.1", "legacy-mini-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "mini", + "1.0.1", + ); + + expect(artifacts).toEqual([]); + }); + it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => { const version = "9.9.4"; @@ -136,6 +178,7 @@ describe("sync-releases script", () => { mockS3ListVersions("app", [version, "10.0.0-beta.1"]); mockS3ListVersions("system", [version]); + mockS3ListVersions("mini", []); mockS3HashFile("app", version, "app-hash"); mockS3SkuVersion("system", version, DEFAULT_SKU, "system-hash-v2"); mockS3SkuVersion("system", version, SDMMC_SKU, "system-hash-sdmmc"); From f7ad77962cc3e5c3896e96a9b9c42b546f4a4b3e Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 18:26:05 +0200 Subject: [PATCH 5/8] Devices: report the hardware SKU a connected device sends (#72) Devices send X-Device-SKU on the signaling connection alongside X-App-Version. Keep it with the live connection state, validated against the product registry so an unknown value is logged and dropped rather than advertised, and report it from GET /devices and GET /devices/:id. A device that sends no SKU is the original hardware. The connection tuple in activeConnections becomes a named struct so the new field does not extend a positional array. --- src/devices.ts | 37 +++++++---- src/skus.ts | 18 ++++++ src/webrtc-signaling.ts | 30 ++++++--- src/webrtc.ts | 7 +- test/devices.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++ test/skus.test.ts | 19 ++++++ 6 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 test/devices.test.ts diff --git a/src/devices.ts b/src/devices.ts index ba42ccf..b6d54d4 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -10,6 +10,22 @@ import { import * as crypto from "crypto"; import { authenticated } from "./auth"; import { activeConnections } from "./webrtc-signaling"; +import { effectiveSku } from "./skus"; + +/** + * Version and SKU are known only while the device holds a signaling + * connection; they arrive as request headers and live in memory, not in the + * database. Offline devices report null for both. An online device that + * sends no SKU header is the original hardware. + */ +function liveDeviceState(id: string) { + const conn = activeConnections.get(id); + return { + online: !!conn, + version: conn?.version || null, + sku: conn ? effectiveSku(conn.sku) : null, + }; +} export const List = async (req: express.Request, res: express.Response) => { const idToken = req.session?.id_token; @@ -24,16 +40,10 @@ export const List = async (req: express.Request, res: express.Response) => { }); return res.json({ - devices: devices.map(device => { - const activeDevice = activeConnections.get(device.id); - const version = activeDevice?.[2] || null; - - return { - ...device, - online: !!activeDevice, - version, - }; - }), + devices: devices.map(device => ({ + ...device, + ...liveDeviceState(device.id), + })), }); } else { throw new BadRequestError("Token is not from Google"); @@ -55,7 +65,7 @@ export const Retrieve = async ( }); if (!device) throw new NotFoundError("Device not found"); - return res.status(200).json({ device }); + return res.status(200).json({ device: { ...device, ...liveDeviceState(device.id) } }); }; export const Update = async ( @@ -137,9 +147,8 @@ export const Delete = async ( // We just removed the device, so we should close any running open socket connections const conn = activeConnections.get(id); if (conn) { - const [socket] = conn; - socket.send("Deregistered from server"); - socket.close(); + conn.ws.send("Deregistered from server"); + conn.ws.close(); } return res.status(204).send(); diff --git a/src/skus.ts b/src/skus.ts index 1f8c494..a249c61 100644 --- a/src/skus.ts +++ b/src/skus.ts @@ -67,6 +67,24 @@ export function isKnownSku(sku: string): boolean { return Object.prototype.hasOwnProperty.call(SKUS, sku); } +/** + * Coerces a SKU reported by a device (header or body value) to a registered + * SKU, or null when absent or unknown. Unknown values are dropped rather than + * stored so the cloud never advertises hardware it has no definition for. + */ +export function normalizeSku(value: unknown): string | null { + return typeof value === "string" && isKnownSku(value) ? value : null; +} + +/** + * SKU to assume for a connected device that did not report one. Only the + * original JetKVM hardware predates SKU reporting, so it is the default + * everywhere, mirroring the /releases query default. + */ +export function effectiveSku(sku: string | null | undefined): string { + return sku ?? DEFAULT_SKU; +} + export interface Artifact { prefix: string; file: string; diff --git a/src/webrtc-signaling.ts b/src/webrtc-signaling.ts index 879b68b..c29f788 100644 --- a/src/webrtc-signaling.ts +++ b/src/webrtc-signaling.ts @@ -8,10 +8,19 @@ import { Socket } from "node:net"; import { Device } from "@prisma/client"; import { Server, ServerResponse } from "node:http"; import { cookieSessionMiddleware } from "."; +import { effectiveSku, normalizeSku } from "./skus"; + +export interface DeviceConnection { + ws: WebSocket; + ip: string; + /** X-App-Version header, null for firmware that does not send it. */ + version: string | null; + /** X-Device-SKU header after validation, null when absent or unknown. */ + sku: string | null; +} // Maintain the shared state -export const activeConnections: Map = - new Map(); // [deviceWs, ip, version] +export const activeConnections: Map = new Map(); export const inFlight: Set = new Set(); function toICEServers(str: string) { @@ -86,7 +95,7 @@ async function handleDeviceSocketRequest( `[Device] Device ${device.id} already connected. Terminating existing connection.`, ); - const [existingDeviceWs] = activeConnections.get(device.id)!; + const existingDeviceWs = activeConnections.get(device.id)!.ws; await new Promise(resolve => { console.log("[Device] Waiting for existing connection to close..."); existingDeviceWs.on("close", () => { @@ -148,12 +157,17 @@ function setupDeviceWebSocket(deviceWs: WebSocket, device: Device, req: Incoming (process.env.REAL_IP_HEADER && req.headers[process.env.REAL_IP_HEADER]) || req.socket.remoteAddress; - const deviceVersion = req.headers["x-app-version"] as string | null; + const deviceVersion = (req.headers["x-app-version"] as string | undefined) || null; + const rawSku = req.headers["x-device-sku"]; + const deviceSku = normalizeSku(rawSku); + if (rawSku && !deviceSku) { + console.log(`[Device] ${id} reported unknown SKU ${JSON.stringify(rawSku)}; ignoring`); + } // Store the connection - activeConnections.set(id, [deviceWs, `${ip}`, deviceVersion || null]); + activeConnections.set(id, { ws: deviceWs, ip: `${ip}`, version: deviceVersion, sku: deviceSku }); console.log( - `[Device] New connection for device ${id}, with version ${deviceVersion || "unknown"}`, + `[Device] New connection for device ${id}, with version ${deviceVersion || "unknown"} and SKU ${deviceSku || "unknown"}`, ); // Setup ping/pong for connection health checks @@ -289,7 +303,7 @@ function setupClientWebSocket(clientWs: WebSocket, deviceId: string, token: stri return clientWs.close(); } - const [deviceWs, ip, version] = deviceConn; + const { ws: deviceWs, ip, version, sku } = deviceConn; // If there's an active connection with this device, prevent a new one if (inFlight.has(deviceId)) { @@ -307,7 +321,7 @@ function setupClientWebSocket(clientWs: WebSocket, deviceId: string, token: stri clientWs.send( JSON.stringify({ type: "device-metadata", - data: { deviceVersion: version }, + data: { deviceVersion: version, deviceSku: effectiveSku(sku) }, }), ); diff --git a/src/webrtc.ts b/src/webrtc.ts index 06cc2a0..1d146de 100644 --- a/src/webrtc.ts +++ b/src/webrtc.ts @@ -30,14 +30,13 @@ export const CreateSession = async (req: express.Request, res: express.Response) ); } - const wsTuple = activeConnections.get(id); - if (!wsTuple) { + const deviceConn = activeConnections.get(id); + if (!deviceConn) { console.log("No socket for id", id); throw new NotFoundError(`No socket for id found`, "kvm_socket_not_found"); } - // extract the websocket and ip from the tuple - const [ws, ip] = wsTuple; + const { ws, ip } = deviceConn; let timeout: NodeJS.Timeout | undefined; diff --git a/test/devices.test.ts b/test/devices.test.ts new file mode 100644 index 0000000..307105d --- /dev/null +++ b/test/devices.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Request, Response } from "express"; + +import { testPrisma } from "./setup"; + +// webrtc-signaling pulls in src/index.ts (the listening server). Devices only +// need the connection map, so stub the module with an empty one. +const activeConnections = new Map(); +vi.mock("../src/webrtc-signaling", () => ({ activeConnections })); + +const { List, Retrieve, Token } = await import("../src/devices"); + +const GOOGLE_ID = "devices-test-google-id"; +const DEVICE_ID = "devices-test-device"; +const TEMP_TOKEN = "devices-test-temp-token"; + +function unsignedJwt(payload: Record): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.`; +} + +function tokenRequest(body: Record): Request { + return { body } as unknown as Request; +} + +function sessionRequest(params: Record = {}): Request { + return { + params, + session: { id_token: unsignedJwt({ iss: "https://accounts.google.com", sub: GOOGLE_ID }) }, + } as unknown as Request; +} + +function mockResponse(): Response & { _json: any } { + const res = { + _json: null, + status: vi.fn(function (this: any) { + return this; + }), + json: vi.fn(function (this: any, data: any) { + this._json = data; + return this; + }), + }; + return res as unknown as Response & { _json: any }; +} + +describe("device live state", () => { + beforeEach(async () => { + activeConnections.clear(); + const user = await testPrisma.user.upsert({ + where: { googleId: GOOGLE_ID }, + update: {}, + create: { googleId: GOOGLE_ID }, + }); + await testPrisma.device.create({ + data: { + id: DEVICE_ID, + userId: user.id, + tempToken: TEMP_TOKEN, + tempTokenExpiresAt: new Date(Date.now() + 60_000), + }, + }); + }); + + afterEach(async () => { + await testPrisma.device.deleteMany({ where: { id: DEVICE_ID } }); + await testPrisma.user.deleteMany({ where: { googleId: GOOGLE_ID } }); + }); + + it("issues a secret token and consumes the temp token", async () => { + const res = mockResponse(); + await Token(tokenRequest({ tempToken: TEMP_TOKEN }), res); + + expect(res._json.secretToken).toMatch(/^[0-9a-f]{40}$/); + const device = await testPrisma.device.findUniqueOrThrow({ where: { id: DEVICE_ID } }); + expect(device.tempToken).toBeNull(); + }); + + it("reports no version or SKU for an offline device", async () => { + const res = mockResponse(); + await Retrieve(sessionRequest({ id: DEVICE_ID }), res); + + expect(res._json.device).toMatchObject({ + id: DEVICE_ID, + online: false, + version: null, + sku: null, + }); + }); + + it("treats an online device that sent no SKU as the original hardware", async () => { + activeConnections.set(DEVICE_ID, { ws: {}, ip: "10.0.0.2", version: "0.5.9", sku: null }); + + const res = mockResponse(); + await Retrieve(sessionRequest({ id: DEVICE_ID }), res); + + expect(res._json.device).toMatchObject({ online: true, version: "0.5.9", sku: "jetkvm-v2" }); + }); + + it("reports the SKU and version from the live connection", async () => { + activeConnections.set(DEVICE_ID, { + ws: {}, + ip: "10.0.0.2", + version: "1.2.3", + sku: "jetkvm-mini-ethernet", + }); + + const res = mockResponse(); + await Retrieve(sessionRequest({ id: DEVICE_ID }), res); + + expect(res._json.device).toMatchObject({ + sku: "jetkvm-mini-ethernet", + online: true, + version: "1.2.3", + }); + }); + + it("lists devices with the same live state", async () => { + activeConnections.set(DEVICE_ID, { + ws: {}, + ip: "10.0.0.2", + version: "1.2.3", + sku: "jetkvm-mini-wireless", + }); + + const res = mockResponse(); + await List(sessionRequest(), res); + + expect(res._json.devices).toEqual([ + expect.objectContaining({ + id: DEVICE_ID, + online: true, + version: "1.2.3", + sku: "jetkvm-mini-wireless", + }), + ]); + }); +}); diff --git a/test/skus.test.ts b/test/skus.test.ts index e61f208..2235aea 100644 --- a/test/skus.test.ts +++ b/test/skus.test.ts @@ -5,8 +5,10 @@ import { KNOWN_SKUS, OTA_PREFIXES, artifactFor, + effectiveSku, isKnownSku, legacyCompatibleSkus, + normalizeSku, otaArtifacts, otaFileForPrefix, skusForPrefix, @@ -81,3 +83,20 @@ describe("SKU table", () => { ); }); }); + +describe("device-reported SKUs", () => { + it("normalizes a reported SKU to a registered one or null", () => { + expect(normalizeSku("jetkvm-mini-ethernet")).toBe("jetkvm-mini-ethernet"); + expect(normalizeSku("jetkvm-mini-lte")).toBeNull(); + expect(normalizeSku(["jetkvm-v2"])).toBeNull(); + expect(normalizeSku(undefined)).toBeNull(); + expect(normalizeSku("")).toBeNull(); + expect(normalizeSku("__proto__")).toBeNull(); + }); + + it("treats a device without a stored SKU as the original hardware", () => { + expect(effectiveSku(null)).toBe(DEFAULT_SKU); + expect(effectiveSku(undefined)).toBe(DEFAULT_SKU); + expect(effectiveSku("jetkvm-mini-ethernet")).toBe("jetkvm-mini-ethernet"); + }); +}); From 80b5437f2dede5e388d85b2db77ad9dff7a69062 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 18:26:40 +0200 Subject: [PATCH 6/8] Releases: recovery image per product (#73) The Mini's recovery image is a merged full-flash binary: bootloader, partition table, the firmware in the first OTA slot and erased OTA selection data, written with esptool or a browser flasher at offset 0. It is built with the firmware and shares its version, so it lives next to it: mini//skus//jetkvm-mini-full.bin. The recovery redirect already reads the prefix and file name from the product table, so registering the artifact is the whole change. JetKVM redirects are unchanged. --- src/skus.ts | 7 ++++- test/releases.test.ts | 67 +++++++++++++++++++++++++++++++++++++++---- test/skus.test.ts | 9 ++++-- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/skus.ts b/src/skus.ts index a249c61..b58f337 100644 --- a/src/skus.ts +++ b/src/skus.ts @@ -50,12 +50,17 @@ export const SKUS: Record = { // JetKVM Mini (ESP32-P4). One firmware image is the whole system: // FreeRTOS, drivers, the application, the web UI and the ESP32-C5 Wi-Fi // co-processor firmware. There is no separate app. The Ethernet (IP101) - // and wireless (ESP32-C5 over SDIO) boards are separate builds. + // and wireless (ESP32-C5 over SDIO) boards are separate builds. The + // recovery image is the merged full-flash binary (bootloader, partition + // table, firmware in the first OTA slot, erased OTA selection data), + // written with esptool or a browser flasher at offset 0. "jetkvm-mini-ethernet": { system: "mini/jetkvm-mini.bin", + recovery: "mini/jetkvm-mini-full.bin", }, "jetkvm-mini-wireless": { system: "mini/jetkvm-mini.bin", + recovery: "mini/jetkvm-mini-full.bin", }, }; diff --git a/test/releases.test.ts b/test/releases.test.ts index c3c7174..2294de9 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -1460,15 +1460,70 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { ); }); - it("should throw BadRequestError for a SKU without a recovery image", async () => { - const req = createMockRequest({ sku: MINI_ETHERNET_SKU }); + it("redirects a mini SKU to the full-flash image under the mini prefix", async () => { + s3Mock.on(ListObjectsV2Command, { Prefix: "mini/" }).resolves({ + CommonPrefixes: [{ Prefix: "mini/1.0.0/" }, { Prefix: "mini/1.1.0/" }], + }); + + const content = "mini-full-flash"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + mockS3SkuVersionWithContent( + "mini", + "1.1.0", + MINI_WIRELESS_SKU, + "jetkvm-mini-full.bin", + content, + hash, + ); + const res = createMockResponse(); + await RetrieveLatestSystemRecovery(createMockRequest({ sku: MINI_WIRELESS_SKU }), res); - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - BadRequestError, + expect(res.redirect).toHaveBeenCalledWith( + 302, + `https://cdn.test.com/mini/1.1.0/skus/${MINI_WIRELESS_SKU}/jetkvm-mini-full.bin`, ); - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - 'SKU "jetkvm-mini-ethernet" has no downloadable recovery image', + expect(s3Mock.commandCalls(ListObjectsV2Command, { Prefix: "system/" }).length).toBe(0); + }); + + it("keeps separate recovery cache entries for JetKVM and mini SKUs", async () => { + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + s3Mock.on(ListObjectsV2Command, { Prefix: "mini/" }).resolves({ + CommonPrefixes: [{ Prefix: "mini/1.0.0/" }], + }); + const crypto = await import("crypto"); + const systemContent = "system-recovery"; + const miniContent = "mini-full-flash"; + mockS3SkuVersionWithContent( + "system", + "2.0.0", + DEFAULT_SKU, + "update.img", + systemContent, + crypto.createHash("sha256").update(systemContent).digest("hex"), + ); + mockS3SkuVersionWithContent( + "mini", + "1.0.0", + MINI_ETHERNET_SKU, + "jetkvm-mini-full.bin", + miniContent, + crypto.createHash("sha256").update(miniContent).digest("hex"), + ); + + const systemRes = createMockResponse(); + await RetrieveLatestSystemRecovery(createMockRequest({ sku: DEFAULT_SKU }), systemRes); + const miniRes = createMockResponse(); + await RetrieveLatestSystemRecovery(createMockRequest({ sku: MINI_ETHERNET_SKU }), miniRes); + + expect(systemRes._redirectUrl).toBe( + "https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/update.img", + ); + expect(miniRes._redirectUrl).toBe( + `https://cdn.test.com/mini/1.0.0/skus/${MINI_ETHERNET_SKU}/jetkvm-mini-full.bin`, ); }); diff --git a/test/skus.test.ts b/test/skus.test.ts index 2235aea..6152ef4 100644 --- a/test/skus.test.ts +++ b/test/skus.test.ts @@ -41,7 +41,7 @@ describe("SKU table", () => { } }); - it("keeps the JetKVM recovery image with the system artifact and gives the Mini none", () => { + it("stores each product's recovery image next to what it is built with", () => { expect(artifactFor("jetkvm-v2", "recovery")).toEqual({ prefix: "system", file: "update.img", @@ -51,11 +51,14 @@ describe("SKU table", () => { file: "update_sd.img.zip", }); for (const sku of MINI_SKUS) { - expect(artifactFor(sku, "recovery")).toBeUndefined(); + expect(artifactFor(sku, "recovery")).toEqual({ + prefix: "mini", + file: "jetkvm-mini-full.bin", + }); } }); - it("syncs three prefixes, each holding one over-the-air file", () => { + it("syncs three prefixes, each holding one over-the-air file (recovery is not synced)", () => { expect(OTA_PREFIXES).toEqual(["app", "system", "mini"]); expect(otaFileForPrefix("app")).toBe("jetkvm_app"); expect(otaFileForPrefix("system")).toBe("system.tar"); From 2dbc67b4e49e02bb7e0f7a333a319f2f75b5b19b Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 22:14:33 +0200 Subject: [PATCH 7/8] fix(releases): redirect without downloading the artifact (#75) The latest-artifact redirects fetched the whole object on every cache miss to compare it with its .sha256 sidecar. Several concurrent misses on staging pulled multiple recovery images at once, the instance was marked unhealthy and the requests failed with 504. The sidecar is written by the release script that uploads the file, and the sync script verifies hash and signature when it registers a release, so a second check at request time added nothing. Check that the object exists and redirect. --- src/releases.ts | 57 ++++++--------------------------------- test/releases.test.ts | 63 +++++++------------------------------------ 2 files changed, 18 insertions(+), 102 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 7115c06..d34ba83 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -16,7 +16,6 @@ import { objectKeyFromArtifactUrl, streamToString, toSemverRange, - verifyHash, } from "./helpers"; import { z, ZodError } from "zod"; import { @@ -686,39 +685,19 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( recovery.file, ); - const [firmwareFile, hashFile] = await Promise.all([ - // TODO: store file hash using custom header to avoid extra request - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: artifactPath, - }), - ), - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: `${artifactPath}.sha256`, - }), - ), - ]); - - if (!firmwareFile.Body || !hashFile.Body) { - throw new NotFoundError( - `Recovery image or hash file not found for version ${latestVersion}`, - ); + if (!(await s3ObjectExists(artifactPath))) { + throw new NotFoundError(`Recovery image not found for version ${latestVersion}`); } - await verifyHash(firmwareFile, hashFile, "recovery image hash does not match"); - - console.log("recovery image hash matches", latestVersion); - return `${baseUrl}/${artifactPath}`; }, ); /** - * 302 to the newest over-the-air artifact of one kind for the requested SKU, - * after verifying the object against its .sha256 sibling. The product table + * 302 to the newest over-the-air artifact of one kind for the requested SKU. + * Integrity is checked at publish time (the .sha256 sidecar is written by the + * release script, the sync script verifies hash and signature); here the + * object only has to exist. The product table * says which prefix holds it, so the same URL serves every product. Used by * build tooling (rv1106-system pulls the app binary into the system image) * and by flashing scripts. @@ -763,30 +742,10 @@ function latestArtifactRedirect(kind: OtaKind) { artifact.file, ); - const [artifactFile, hashFile] = await Promise.all([ - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: artifactPath, - }), - ), - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: `${artifactPath}.sha256`, - }), - ), - ]); - - if (!artifactFile.Body || !hashFile.Body) { - throw new NotFoundError( - `${prefix} artifact or hash file not found for version ${latestVersion}`, - ); + if (!(await s3ObjectExists(artifactPath))) { + throw new NotFoundError(`${prefix} artifact not found for version ${latestVersion}`); } - await verifyHash(artifactFile, hashFile, `${prefix} hash does not match`); - - console.log(`${prefix} hash matches`, latestVersion); return `${baseUrl}/${artifactPath}`; }, ); diff --git a/test/releases.test.ts b/test/releases.test.ts index 2294de9..cc32725 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -134,6 +134,9 @@ function mockS3LegacyVersionWithContent( Contents: [], }); + // Legacy artifact exists (HeadObjectCommand for the existence check) + s3Mock.on(HeadObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({}); + // Mock legacy file path with content s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({ Body: createAsyncIterable(content) as any, @@ -929,25 +932,6 @@ describe("RetrieveLatestApp S3 redirect handler", () => { ); }); - it("should throw InternalServerError when hash does not match", async () => { - const req = createMockRequest({}); - const res = createMockResponse(); - - s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ - CommonPrefixes: [{ Prefix: "app/1.0.0/" }], - }); - - mockS3LegacyVersionWithContent( - "app", - "1.0.0", - "jetkvm_app", - "actual-content", - "wrong-hash-value", - ); - - await expect(RetrieveLatestApp(req, res)).rejects.toThrow(InternalServerError); - }); - it("should throw NotFoundError when app file is missing", async () => { const req = createMockRequest({}); const res = createMockResponse(); @@ -961,12 +945,9 @@ describe("RetrieveLatestApp S3 redirect handler", () => { Contents: [], }); - s3Mock.on(GetObjectCommand, { Key: "app/1.0.0/jetkvm_app" }).resolves({ - Body: undefined, - }); - s3Mock.on(GetObjectCommand, { Key: "app/1.0.0/jetkvm_app.sha256" }).resolves({ - Body: createAsyncIterable("some-hash") as any, - }); + s3Mock + .on(HeadObjectCommand, { Key: "app/1.0.0/jetkvm_app" }) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); }); @@ -1304,28 +1285,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { ); }); - it("should throw InternalServerError when hash does not match", async () => { - const req = createMockRequest({}); - const res = createMockResponse(); - - s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ - CommonPrefixes: [{ Prefix: "system/1.0.0/" }], - }); - - mockS3LegacyVersionWithContent( - "system", - "1.0.0", - "update.img", - "actual-content", - "mismatched-hash", - ); - - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - InternalServerError, - ); - }); - - it("should throw NotFoundError when recovery image or hash file is missing", async () => { + it("should throw NotFoundError when recovery image is missing", async () => { const req = createMockRequest({}); const res = createMockResponse(); @@ -1338,12 +1298,9 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { Contents: [], }); - s3Mock.on(GetObjectCommand, { Key: "system/1.0.0/update.img" }).resolves({ - Body: undefined, - }); - s3Mock.on(GetObjectCommand, { Key: "system/1.0.0/update.img.sha256" }).resolves({ - Body: undefined, - }); + s3Mock + .on(HeadObjectCommand, { Key: "system/1.0.0/update.img" }) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); }); From fde6e3ac80a420bb21f89f1bb9d3b84d5b975724 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 18 Sep 2026 22:32:34 +0200 Subject: [PATCH 8/8] chore(compare-releases): cover ranges, older versions and extra SKUs (#76) The comparison only requested the live baseline versions for the two registered JetKVM SKUs, so the pre-skus/ layout, semver ranges and the SKU table's handling of other SKUs were never exercised. Add, per device and default SKU, a caret range and a "