Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 98 additions & 21 deletions src/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ const releaseCache = new LRUCache<string, ReleaseMetadata>({
ttl: 5 * 60 * 1000, // 5 minutes
});

const MISSING_SIG_URL = false;

const sigUrlCache = new LRUCache<string, string | typeof MISSING_SIG_URL>({
max: 1000,
ttl: 5 * 60 * 1000, // 5 minutes
});

const redirectCache = new LRUCache<string, string>({
max: 1000,
ttl: 5 * 60 * 1000, // 5 minutes
Expand All @@ -110,6 +117,7 @@ const redirectCache = new LRUCache<string, string>({
export function clearCaches() {
releaseCache.clear();
redirectCache.clear();
sigUrlCache.clear();
}

const bucketName = process.env.R2_BUCKET;
Expand Down Expand Up @@ -203,6 +211,67 @@ async function resolveArtifactPath(
);
}

/**
* Resolves the signature URL for a given version if a .sig file exists in S3.
* Results are cached for 5 minutes.
*/
async function resolveSigUrl(
prefix: "app" | "system",
version: string,
sku: string,
): Promise<string | undefined> {
const cacheKey = `${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 sigKey = `${path}.sig`;
if (await s3ObjectExists(sigKey)) {
const url = `${baseUrl}/${sigKey}`;
sigUrlCache.set(cacheKey, url);
return url;
}
} catch (error) {
if (error instanceof NotFoundError) {
// Version doesn't exist for this SKU — cache as absent
sigUrlCache.set(cacheKey, MISSING_SIG_URL);
return undefined;
}
// Don't cache transient errors (network, permissions, etc.)
throw error;
}

sigUrlCache.set(cacheKey, MISSING_SIG_URL);
return undefined;
}

/**
* Enriches a Release response with signature URLs by checking S3 for .sig files.
* Transient S3 errors are logged but don't block the response — sigUrl is optional.
*/
async function enrichWithSigUrls(release: Release, sku: string): Promise<void> {
const [appSigUrl, systemSigUrl] = await Promise.all([
release.appVersion
? resolveSigUrl("app", release.appVersion, sku).catch(e => {
console.error(`Failed to resolve app sig URL for ${release.appVersion}:`, e);
return undefined;
})
: undefined,
release.systemVersion
? resolveSigUrl("system", release.systemVersion, sku).catch(e => {
console.error(
`Failed to resolve system sig URL for ${release.systemVersion}:`,
e,
);
return undefined;
})
: undefined,
]);
if (appSigUrl) release.appSigUrl = appSigUrl;
if (systemSigUrl) release.systemSigUrl = systemSigUrl;
}

async function getLatestVersion(
prefix: "app" | "system",
includePrerelease: boolean,
Expand Down Expand Up @@ -257,7 +326,7 @@ async function getLatestVersion(
const hash = await streamToString(hashResponse.Body);

// Cache the release metadata
const release = {
const release: ReleaseMetadata = {
version: latestVersion,
url,
hash,
Expand All @@ -272,12 +341,14 @@ interface Release {
appVersion: string;
appUrl: string;
appHash: string;
appSigUrl?: string;
appCachedAt?: number;
appMaxSatisfying?: string;

systemVersion: string;
systemUrl: string;
systemHash: string;
systemSigUrl?: string;
systemCachedAt?: number;
systemMaxSatisfying?: string;
}
Expand Down Expand Up @@ -387,6 +458,7 @@ export async function Retrieve(req: Request, res: Response) {

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

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

const defaultAppRelease = await getDefaultRelease("app");
const defaultSystemRelease = await getDefaultRelease("system");
responseJson = toRelease(defaultAppRelease, defaultSystemRelease);

const responseJson = toRelease(defaultAppRelease, defaultSystemRelease);
if (
await isDeviceEligibleForLatestRelease(
latestAppRelease.rolloutPercentage,
query.deviceId,
)
) {
setAppRelease(responseJson, latestAppRelease);
}

if (
await isDeviceEligibleForLatestRelease(
latestAppRelease.rolloutPercentage,
query.deviceId,
)
) {
setAppRelease(responseJson, latestAppRelease);
if (
await isDeviceEligibleForLatestRelease(
latestSystemRelease.rolloutPercentage,
query.deviceId,
)
) {
setSystemRelease(responseJson, latestSystemRelease);
}
}

if (
await isDeviceEligibleForLatestRelease(
latestSystemRelease.rolloutPercentage,
query.deviceId,
)
) {
setSystemRelease(responseJson, latestSystemRelease);
}
// DB records don't store sigUrl. Resolve from S3 for the versions being served.
// The device requires sigUrl for stable (non-prerelease) GPG signature verification.
await enrichWithSigUrls(responseJson, query.sku);

return res.json(responseJson);
}
Expand Down
122 changes: 120 additions & 2 deletions test/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,24 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) {
}

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

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

// Mock legacy hash path
s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({
s3Mock.on(GetObjectCommand, { Key: `${artifactPath}.sha256` }).resolves({
Body: createAsyncIterable(hash) as any,
});

// Mock .sig existence check (absence handled by default HeadObject reject in beforeEach)
if (opts?.hasSig) {
s3Mock.on(HeadObjectCommand, { Key: `${artifactPath}.sig` }).resolves({});
}
}

// Mock S3 for versions with SKU support
Expand All @@ -67,6 +73,7 @@ function mockS3SkuVersion(
version: string,
sku: string,
hash: string,
opts?: { hasSig?: boolean },
) {
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`;
Expand All @@ -83,6 +90,11 @@ function mockS3SkuVersion(
s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({
Body: createAsyncIterable(hash) as any,
});

// Mock .sig existence check (absence handled by default HeadObject reject in beforeEach)
if (opts?.hasSig) {
s3Mock.on(HeadObjectCommand, { Key: `${skuPath}.sig` }).resolves({});
}
}


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

Expand Down Expand Up @@ -451,6 +466,68 @@ describe("Retrieve handler", () => {
});
});

describe("signature URL handling", () => {
it("should include sigUrl when .sig file exists", async () => {
const req = createMockRequest({
deviceId: "device-sig",
prerelease: "true",
appVersion: "^6.0.0",
systemVersion: "^6.0.0",
});
const res = createMockResponse();

mockS3ListVersions("app", ["6.0.0"]);
mockS3ListVersions("system", ["6.0.0"]);
mockS3HashFile("app", "6.0.0", "sig-app-hash", { hasSig: true });
mockS3HashFile("system", "6.0.0", "sig-system-hash", { hasSig: true });

await Retrieve(req, res);

expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/6.0.0/jetkvm_app.sig");
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/6.0.0/system.tar.sig");
});

it("should omit sigUrl when .sig file does not exist", async () => {
const req = createMockRequest({
deviceId: "device-nosig",
prerelease: "true",
appVersion: "^7.0.0",
systemVersion: "^7.0.0",
});
const res = createMockResponse();

mockS3ListVersions("app", ["7.0.0"]);
mockS3ListVersions("system", ["7.0.0"]);
mockS3HashFile("app", "7.0.0", "nosig-app-hash");
mockS3HashFile("system", "7.0.0", "nosig-system-hash");

await Retrieve(req, res);

expect(res._json.appSigUrl).toBeUndefined();
expect(res._json.systemSigUrl).toBeUndefined();
});

it("should include sigUrl with SKU path when .sig file exists", async () => {
const req = createMockRequest({
deviceId: "device-sku-sig",
sku: "jetkvm-2",
appVersion: "^8.0.0",
systemVersion: "^8.0.0",
});
const res = createMockResponse();

mockS3ListVersions("app", ["8.0.0"]);
mockS3ListVersions("system", ["8.0.0"]);
mockS3SkuVersion("app", "8.0.0", "jetkvm-2", "sku-sig-app-hash", { hasSig: true });
mockS3SkuVersion("system", "8.0.0", "jetkvm-2", "sku-sig-system-hash", { hasSig: true });

await Retrieve(req, res);

expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/8.0.0/skus/jetkvm-2/jetkvm_app.sig");
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig");
});
});

describe("forceUpdate mode", () => {
it("should return latest release when forceUpdate=true", async () => {
// Use unique version constraints to get unique cache keys
Expand All @@ -473,6 +550,25 @@ describe("Retrieve handler", () => {
expect(res._json.appVersion).toBe("1.5.5");
expect(res._json.systemVersion).toBe("1.5.5");
});

it("should include sigUrl when forceUpdate=true and .sig file exists", async () => {
const req = createMockRequest({
deviceId: "device-force-sig",
forceUpdate: "true",
});
const res = createMockResponse();

mockS3ListVersions("app", ["10.0.0"]);
mockS3ListVersions("system", ["10.0.0"]);
mockS3HashFile("app", "10.0.0", "force-sig-app-hash", { hasSig: true });
mockS3HashFile("system", "10.0.0", "force-sig-system-hash", { hasSig: true });

await Retrieve(req, res);

expect(res._json.appVersion).toBe("10.0.0");
expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/10.0.0/jetkvm_app.sig");
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/10.0.0/system.tar.sig");
});
});

describe("rollout logic", () => {
Expand Down Expand Up @@ -571,6 +667,28 @@ describe("Retrieve handler", () => {
expect(res._json.appVersion).toBe("1.2.0");
expect(res._json.systemVersion).toBe("1.1.0");
});

it("should include sigUrl for rollout-eligible device when .sig file exists", async () => {
await setRollout("1.1.0", "app", 100);
await setRollout("1.1.0", "system", 100);
await setRollout("1.2.0", "app", 100);
await setRollout("1.2.0", "system", 100);

const deviceId = findDeviceIdInsideRollout(100);
const req = createMockRequest({ deviceId });
const res = createMockResponse();

mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]);
mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]);
mockS3HashFile("app", "1.2.0", "rollout-sig-app-hash", { hasSig: true });
mockS3HashFile("system", "1.2.0", "rollout-sig-system-hash", { hasSig: true });

await Retrieve(req, res);

expect(res._json.appVersion).toBe("1.2.0");
expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/1.2.0/jetkvm_app.sig");
expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/1.2.0/system.tar.sig");
});
});

describe("default release handling", () => {
Expand Down