From a17183f15c43640ec39936e9a6db9d964a69d14e Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Wed, 21 Oct 2026 17:32:56 -0400 Subject: [PATCH] Add hosted Studio template distribution (#139) --- apps/studio/README.md | 16 ++ apps/studio/src/context.ts | 2 + apps/studio/src/distribution.ts | 213 ++++++++++++++++++ apps/studio/src/studio.app.ts | 2 + .../src/test/integration/distribution.test.ts | 132 +++++++++++ 5 files changed, 365 insertions(+) create mode 100644 apps/studio/src/distribution.ts create mode 100644 apps/studio/src/test/integration/distribution.test.ts diff --git a/apps/studio/README.md b/apps/studio/README.md index e85004e..8fab3cf 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -50,6 +50,22 @@ just migrate -F studio # remote just migrate -F studio -- --local # dev db ``` +## Launcher template distribution + +Set `STUDIO_DISTRIBUTION_MANIFEST` to an R2 catalog key to enable the optional +PatchKit-compatible `/1/apps/:secret/*` routes. The catalog and ZIP parts are +generated by `studio-patch-2026/build_distribution.py`; upload all parts through +the existing CDN before publishing the catalog. No template bytes or operator +configuration belong in this repository. + +The catalog names the template application's SHA-256 identifier, latest release, +HTTPS multipart content URL, file hashes, and download-chunk hashes. Optional +legacy summaries supply comparison hashes without making old templates +downloadable. The application identifier scopes requests; it is not an access +credential. Missing or disabled catalogs return 404. Invalid catalogs or catalogs +larger than 8 MiB return 503; responses use `Cache-Control: no-store` so a corrected +publication can be retried immediately. + ## Editor build limits `GET /cloud-builds/get-rate-limit-config` returns the editor's diff --git a/apps/studio/src/context.ts b/apps/studio/src/context.ts index 0c2b8fd..e08e3c3 100644 --- a/apps/studio/src/context.ts +++ b/apps/studio/src/context.ts @@ -21,6 +21,8 @@ export type Env = SharedHonoEnv & { CLOUD_BUILD_KEY?: string CLOUD_BUILD_TARGETS?: string CLOUD_BUILD_TEMPLATE_VERSION?: string + /** R2 key of an operator-published template catalog; unset disables downloads. */ + STUDIO_DISTRIBUTION_MANIFEST?: string } /** Variables can be extended */ diff --git a/apps/studio/src/distribution.ts b/apps/studio/src/distribution.ts new file mode 100644 index 0000000..6ccec6b --- /dev/null +++ b/apps/studio/src/distribution.ts @@ -0,0 +1,213 @@ +import { Hono } from 'hono' + +import type { Context } from 'hono' +import type { App } from './context' + +type Release = { + version: number + label: string + contentUrl: string + partSize: number + summary: Record +} +type Distribution = { + appSecretSha256: string + latest: number + releases: Release[] + legacySummaries?: Record> +} + +const record = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) +const integer = (value: unknown, minimum = 0): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum +const hex = (value: unknown, length: number): value is string => + typeof value === 'string' && new RegExp(`^[a-f0-9]{${length}}$`).test(value) + +function validSummary(value: unknown): value is Record { + if (!record(value) || !Array.isArray(value.files)) return false + const paths = new Set() + return value.files.every((file: unknown) => { + if (!record(file) || typeof file.path !== 'string' || !hex(file.hash, 8) || !integer(file.size)) + return false + const path = file.path + if ( + path.includes('\\') || + /[\p{Cc}<>:"|?*]/u.test(path) || + path + .split('/') + .some((part) => !part || part === '.' || part === '..' || /[. ]$/.test(part)) || + paths.has(path.toLowerCase()) + ) + return false + paths.add(path.toLowerCase()) + return true + }) +} + +function validCatalog(value: unknown): value is Distribution { + if ( + !record(value) || + !hex(value.appSecretSha256, 64) || + !integer(value.latest, 1) || + !Array.isArray(value.releases) || + value.releases.length === 0 + ) + return false + const versions = new Set() + for (const release of value.releases) { + if ( + !record(release) || + !integer(release.version, 1) || + versions.has(release.version) || + typeof release.label !== 'string' || + !integer(release.partSize, 1) || + typeof release.contentUrl !== 'string' || + !validSummary(release.summary) + ) + return false + let url: URL + try { + url = new URL(release.contentUrl) + } catch { + return false + } + const summary = release.summary + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.hash || + summary.compression_method !== 'zip' || + !hex(summary.hash_code, 64) || + !integer(summary.size, 1) || + !record(summary.chunks) || + summary.chunks.size !== release.partSize || + !Array.isArray(summary.chunks.hashes) || + summary.chunks.hashes.length !== Math.ceil(summary.size / release.partSize) || + !summary.chunks.hashes.every((hash: unknown) => hex(hash, 8)) + ) + return false + versions.add(release.version) + } + if (!versions.has(value.latest)) return false + return ( + value.legacySummaries === undefined || + (record(value.legacySummaries) && + Object.entries(value.legacySummaries).every( + ([id, summary]) => + /^[1-9]\d*$/.test(id) && + Number.isSafeInteger(Number(id)) && + !versions.has(Number(id)) && + validSummary(summary) + )) + ) +} + +/** Optional operator-published PatchKit catalog. No template or deployment identity in source. */ +export default new Hono().get('/1/apps/:secret/*', serve).get('/1/apps/:secret', serve) + +async function serve(c: Context) { + c.header('Cache-Control', 'no-store') + const key = c.env.STUDIO_DISTRIBUTION_MANIFEST + if (!key) return c.notFound() + const manifest = await c.env.CDN_ASSETS.get(key) + if (!manifest) return c.notFound() + if (manifest.size > 8 * 1024 * 1024) return c.body(null, 503) + let catalog: unknown + try { + catalog = await manifest.json() + } catch { + return c.body(null, 503) + } + if (!validCatalog(catalog)) return c.body(null, 503) + const secret = c.req.param('secret') ?? '' + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret)) + const hash = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('') + if (hash !== catalog.appSecretSha256) return c.notFound() + const suffix = new URL(c.req.url).pathname.split('/').slice(4).join('/') + const version = (release: Release) => { + const contentGuid = + typeof release.summary.hash_code === 'string' + ? release.summary.hash_code + : String(release.version) + return { + id: release.version, + label: release.label, + changelog: release.label, + published: true, + draft: false, + pending_publish: false, + publish_progress: 1, + main_executable: 'dummy.exe', + main_executable_args: '', + ignored_files: [], + content_guid: contentGuid, + content_meta_guid: contentGuid, + patch_links: { incoming: [], outgoing: [] }, + } + } + if (suffix === '') + return c.json({ + secret, + display_name: 'Rec Room Studio', + name: 'Rec Room Studio', + platform: 'windows', + current_version: catalog.latest, + lowest_version_with_diff: 0, + use_keys: false, + use_token: false, + use_password: false, + patcher_whitelabel: false, + patcher_download_speed_unit: 'bytes', + }) + if (suffix === 'versions/latest/id') return c.json({ id: catalog.latest }) + if (suffix === 'changelog') + return c.json({ + versions: catalog.releases.map((r) => ({ + vid: r.version, + label: r.label, + changelog: r.label, + })), + }) + if (suffix === 'versions') return c.json(catalog.releases.map(version)) + const match = + /^versions\/(latest|[1-9]\d*)(?:\/(content_summary|content_urls|diff_summary|diff_urls))?$/.exec( + suffix + ) + if (!match) return c.notFound() + // File-version comparisons need historical hashes, without offering the stock + // template as an installable release or repeatedly returning 404 per file. + const legacy = catalog.legacySummaries?.[match[1]!] + if (match[2] === 'content_summary' && legacy) return c.json(legacy) + if (!match[2] && legacy) + return c.json( + version({ + version: Number(match[1]), + label: `v${match[1]}`, + summary: legacy, + partSize: 0, + contentUrl: '', + }) + ) + const release = catalog.releases.find( + (r) => r.version === (match[1] === 'latest' ? catalog.latest : Number(match[1])) + ) + if (!release) return c.notFound() + if (match[2] === 'content_summary') return c.json(release.summary) + if (match[2] === 'diff_summary') { + // A full-replacement update, rather than binary deltas. A successful empty + // delta list is cached by PatchKit; a 404 is retried for every installed file. + const { files, ...summary } = release.summary + return c.json({ + ...summary, + added_files: (files as Array<{ path: string }>).map((file) => file.path), + modified_files: [], + unchanged_files: [], + removed_files: [], + }) + } + if (match[2] === 'content_urls' || match[2] === 'diff_urls') + return c.json([{ url: release.contentUrl, part_size: release.partSize }]) + return c.json(version(release)) +} diff --git a/apps/studio/src/studio.app.ts b/apps/studio/src/studio.app.ts index 7663567..295c57a 100644 --- a/apps/studio/src/studio.app.ts +++ b/apps/studio/src/studio.app.ts @@ -11,6 +11,7 @@ import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' import cloudBuilds from './cloud-builds' +import distribution from './distribution' import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' import { parseMultipart } from './multipart' @@ -162,6 +163,7 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) .route('/', cloudBuilds) + .route('/', distribution) .get('/', async (c) => { return c.text('hello, world!') diff --git a/apps/studio/src/test/integration/distribution.test.ts b/apps/studio/src/test/integration/distribution.test.ts new file mode 100644 index 0000000..d2baa01 --- /dev/null +++ b/apps/studio/src/test/integration/distribution.test.ts @@ -0,0 +1,132 @@ +import { env, SELF } from 'cloudflare:test' +import { beforeEach, expect, it } from 'vitest' + +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + +const origin = 'https://example.com/1/apps/template-fixture' +beforeEach(async () => { + env.STUDIO_DISTRIBUTION_MANIFEST = 'distribution/catalog.json' + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('template-fixture')) + await env.CDN_ASSETS.put( + env.STUDIO_DISTRIBUTION_MANIFEST, + JSON.stringify({ + appSecretSha256: Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, '0') + ).join(''), + latest: 170, + legacySummaries: { '169': { files: [{ path: 'original.tgz', hash: '87654321', size: 80 }] } }, + releases: [ + { + version: 170, + label: 'v170', + partSize: 32, + contentUrl: 'https://cdn.example.test/template.zip', + summary: { + hash_code: 'a'.repeat(64), + compression_method: 'zip', + size: 50, + files: [{ path: 'patched.tgz', hash: '12345678', size: 80 }], + chunks: { size: 32, hashes: ['12345678', '87654321'] }, + }, + }, + ], + }) + ) +}) +it('serves the prepatched v170 catalog and complete multipart download contract', async () => { + expect(await (await SELF.fetch(origin + '/changelog')).json()).toEqual({ + versions: [{ vid: 170, label: 'v170', changelog: 'v170' }], + }) + expect(await (await SELF.fetch(origin + '/versions/latest/id')).json()).toEqual({ id: 170 }) + expect(await (await SELF.fetch(origin)).json()).toMatchObject({ + current_version: 170, + use_keys: false, + }) + expect(await (await SELF.fetch(origin + '/versions/170')).json()).toMatchObject({ + id: 170, + label: 'v170', + patch_links: { incoming: [], outgoing: [] }, + content_guid: 'a'.repeat(64), + }) + expect(await (await SELF.fetch(origin + '/versions/170/content_urls')).json()).toEqual([ + { url: 'https://cdn.example.test/template.zip', part_size: 32 }, + ]) + expect(await (await SELF.fetch(origin + '/versions/170/content_summary')).json()).toMatchObject({ + compression_method: 'zip', + chunks: { hashes: ['12345678', '87654321'] }, + }) +}) + +it('rejects malformed or unsafe catalogs without caching a broken update response', async () => { + const key = env.STUDIO_DISTRIBUTION_MANIFEST! + const original = await (await env.CDN_ASSETS.get(key))!.text() + for (const change of [ + (c: any) => { + c.latest = 171 + }, + (c: any) => { + c.releases.push(c.releases[0]) + }, + (c: any) => { + c.legacySummaries['170'] = c.legacySummaries['169'] + }, + (c: any) => { + c.releases[0].contentUrl = 'http://cdn.example.test/content.zip' + }, + (c: any) => { + c.releases[0].summary.files[0].path = '../outside' + }, + (c: any) => { + c.releases[0].summary.files = null + }, + (c: any) => { + delete c.releases[0].summary.hash_code + }, + (c: any) => { + c.releases[0].summary.chunks.hashes = [] + }, + ]) { + const catalog = JSON.parse(original) + change(catalog) + await env.CDN_ASSETS.put(key, JSON.stringify(catalog)) + const response = await SELF.fetch(origin + '/versions/170/diff_summary') + expect(response.status).toBe(503) + expect(response.headers.get('Cache-Control')).toBe('no-store') + } + await env.CDN_ASSETS.put(key, '{') + expect((await SELF.fetch(origin)).status).toBe(503) + await env.CDN_ASSETS.put(key, ' '.repeat(8 * 1024 * 1024 + 1)) + expect((await SELF.fetch(origin)).status).toBe(503) +}) +it('does not route other applications, missing versions, or disabled catalogs', async () => { + expect((await SELF.fetch('https://example.com/1/apps/unrelated/versions/latest/id')).status).toBe( + 404 + ) + expect(await (await SELF.fetch(origin + '/versions/169')).json()).toMatchObject({ + id: 169, + label: 'v169', + }) + expect((await SELF.fetch(origin + '/versions/168')).status).toBe(404) + expect((await SELF.fetch(origin + '/versions/169/content_urls')).status).toBe(404) + delete env.STUDIO_DISTRIBUTION_MANIFEST + expect((await SELF.fetch(origin + '/versions/latest/id')).status).toBe(404) +}) + +it('provides cached comparison and full-replacement update metadata without legacy downloads', async () => { + expect(await (await SELF.fetch(origin + '/versions/169/content_summary')).json()).toMatchObject({ + files: [{ path: 'original.tgz', hash: '87654321' }], + }) + expect(await (await SELF.fetch(origin + '/versions/170/diff_summary')).json()).toMatchObject({ + compression_method: 'zip', + added_files: ['patched.tgz'], + modified_files: [], + unchanged_files: [], + }) + expect(await (await SELF.fetch(origin + '/versions/170/diff_urls')).json()).toEqual([ + { url: 'https://cdn.example.test/template.zip', part_size: 32 }, + ]) +})