From 4e3301ade8788b11bd0175bbae395e40d7a5d602 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 5 Oct 2026 17:07:42 -0400 Subject: [PATCH] [rooms] support for mitm --- apps/rooms/README.md | 3 + apps/rooms/migrations/0024_unity_asset.sql | 23 ++++ apps/rooms/src/openapi.ts | 29 +++++ apps/rooms/src/rooms.app.ts | 56 +++++++++ apps/rooms/src/test/integration/api.test.ts | 119 ++++++++++++++++++++ packages/domain/src/index.ts | 1 + packages/domain/src/unity-assets-db.ts | 119 ++++++++++++++++++++ 7 files changed, 350 insertions(+) create mode 100644 apps/rooms/migrations/0024_unity_asset.sql create mode 100644 packages/domain/src/unity-assets-db.ts diff --git a/apps/rooms/README.md b/apps/rooms/README.md index a8a3623..a33d5ef 100644 --- a/apps/rooms/README.md +++ b/apps/rooms/README.md @@ -40,6 +40,9 @@ same number, because the client's paged DTO and the reference disagree on the na (target 0 Windows, 2 Android/Quest). `unityAssetTarget` keeps only that target. Maker-pen saves keep the arrays empty. `GET …/unityasset?unityAssetId=` is the bare metadata for those files; the bytes are on the CDN. +`POST /unity_assets/baked/bulk` (form `target=0&version=3&id=&id=`) is the +2025 client's bulk lookup of baked bundles from the `unity_asset` table — a bare array of +`{ UnityAssetId, Target, Version, Filename, Hash }`, one per asset that has a build. ## Cron diff --git a/apps/rooms/migrations/0024_unity_asset.sql b/apps/rooms/migrations/0024_unity_asset.sql new file mode 100644 index 0000000..5abdafc --- /dev/null +++ b/apps/rooms/migrations/0024_unity_asset.sql @@ -0,0 +1,23 @@ +-- Baked unity asset bundles — the blob references behind a room's asset-bundle download. +-- +-- Generated from packages/domain/src/unity-assets-db.ts (UNITY_ASSET_SCHEMA_DDL) — keep in +-- sync. +-- +-- One row per BUILD of a unity asset: the asset's GUID (stored lowercase), the target it was +-- built for (0 Windows, 2 Android/Quest), its version, and the storage blob the client +-- downloads — `filename` is a `/` path and `hash` its base64 SHA-256. The client +-- reads these in bulk from `POST /unity_assets/baked/bulk` (form-encoded +-- `target=0&version=3&id=&id=`) while loading a room, and downloads `filename` +-- from the CDN. A lookup that finds no row downloads nothing, silently. +-- +-- Separate from the Rec Room Studio builds in `studio_unity_asset_file` (the studio worker's, +-- per platform and kind). Nothing writes this table yet. + +CREATE TABLE IF NOT EXISTS unity_asset ( + unity_asset_id TEXT NOT NULL, + target INTEGER NOT NULL, + version INTEGER NOT NULL, + filename TEXT NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (unity_asset_id, target, version) +); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 6715aa9..1863796 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -989,6 +989,35 @@ export const UnityAssetWithSourceDto = z.object({ hash: z.string().describe('Base64 SHA-256 of `filename`'), }) +/** + * `POST /unity_assets/baked/bulk` — form-encoded. `id` is REPEATED, once per unity asset + * (`target=0&version=3&id=&id=`); a comma-separated single value is accepted too. + */ +export const BakedUnityAssetBulkRequest = z.object({ + target: z.string().describe('The build target wanted: 0 Windows, 2 Android/Quest'), + version: z + .string() + .optional() + .describe('The asset version the client wants — preferred when an asset has several builds'), + id: z.array(z.string()).describe('The unity asset GUIDs, repeated once per id'), +}) + +/** + * One baked bundle as `POST /unity_assets/baked/bulk` lists it — the client's five-key DTO + * in its order, PascalCase (unlike the editor's camelCase `bakedUnityAssets` above). The + * client downloads `Filename` from the CDN; an entry without one downloads nothing. + */ +export const BakedUnityAssetBulkDto = z.object({ + UnityAssetId: z.string().describe('GUID, as asked for'), + Target: z.int().describe('0 Windows, 2 Android/Quest — the target asked for'), + Version: z.int().describe('The build’s version, which need not be the one asked for'), + Filename: z.string().describe('The storage blob path, `/`'), + Hash: z.string().describe('Base64 SHA-256 of the blob'), +}) + +/** The bare array `POST /unity_assets/baked/bulk` answers — `[]` when nothing is stored. */ +export const BakedUnityAssetBulkList = z.array(BakedUnityAssetBulkDto) + // ---- Session --------------------------------------------------------------- /** One entry of the permission table the client applies when it spawns into a room. */ diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 4e8c8f2..1108e4b 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -22,6 +22,7 @@ import { deleteSubRoom, findSubRoom, getAccount, + getBakedUnityAssets, getBaseRooms, getContributedRooms, getFavoritedRooms, @@ -99,6 +100,8 @@ import { NotificationType } from '../../notify/src/notification-types' import { AccessibilityRequest, AUTHED, + BakedUnityAssetBulkList, + BakedUnityAssetBulkRequest, bannedPlayerIdParam, BanRequest, BulkRoomsRequest, @@ -4503,6 +4506,59 @@ const app = new Hono() }) ) + // The baked asset bundles behind a room load. The client posts the unity asset ids its + // saves name, with the target it runs on and the asset version it wants, and gets back + // a BARE ARRAY of blob references — `Filename` is what it then downloads from the CDN. + // Form-encoded, `id` repeated once per GUID. No auth, like `…/unityasset`: the room + // document already names these files, and a 401 here would only make the room load + // nothing with no error anywhere — which is also what `[]` does, so an asset with no + // stored build is simply left out rather than answered with a blank row. + .post( + '/unity_assets/baked/bulk', + describeRoute({ + tags: ['Subrooms'], + summary: 'The baked bundles for unity assets', + description: [ + 'The stored builds of the unity assets named by `id` (repeated once per GUID) on', + '`target` (0 Windows, 2 Android/Quest), as a bare array of', + '`{ UnityAssetId, Target, Version, Filename, Hash }` — one entry per asset that has a', + 'build, in the order asked; `[]` when none does. `Filename` is the blob the client', + 'downloads from the CDN, so an entry is never served without one.', + '', + '`version` is a preference, not a filter: an asset built more than once answers the', + 'build at that version when there is one and its newest otherwise. A body naming no', + 'ids, or a non-integer `target`, answers `[]`.', + ].join(' '), + requestBody: form(BakedUnityAssetBulkRequest, 'The assets wanted, and for which target'), + responses: { + 200: json(BakedUnityAssetBulkList, 'The baked bundles, one per asset found'), + }, + }), + async (c) => { + const body = await c.req + .parseBody({ all: true }) + .catch(() => ({}) as Record) + const first = (v: unknown): string => { + const one = Array.isArray(v) ? v[0] : v + return typeof one === 'string' ? one.trim() : '' + } + const target = Number.parseInt(first(body.target), 10) + if (!Number.isInteger(target)) return c.json([]) + // Absent or unparseable, the version prefers nothing and the newest build is served. + const version = Number.parseInt(first(body.version), 10) + // `all: true` keeps the repeated field a list; a single value arrives as a string. + const ids = [body.id, body.ids] + .flat() + .filter((v): v is string => typeof v === 'string') + .flatMap((v) => v.split(',')) + .map((v) => v.trim()) + .filter((v) => v !== '') + return c.json( + await getBakedUnityAssets(c.env.DB, ids, target, Number.isInteger(version) ? version : -1) + ) + } + ) + // Photon access token + room permissions the client needs to spawn into a room. .get( '/photon_access_token', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 47f0f9b..1cae149 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -18,6 +18,7 @@ import { NOTIFICATION_SCHEMA_DDL, PRESENCE_SCHEMA_DDL, publicStudioBundleFilename, + putUnityAsset, ROOM_INSTANCE_SCHEMA_DDL, ROOM_INVITE_SCHEMA_DDL, ROOM_SCHEMA_DDL, @@ -25,6 +26,7 @@ import { sha256HexToBase64, STUDIO_UNITY_ASSET_SCHEMA_DDL, SUBROOM_SCHEMA_DDL, + UNITY_ASSET_SCHEMA_DDL, } from '@repo/domain' import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' @@ -128,6 +130,8 @@ beforeAll(async () => { // Inventions (owned by the api worker) — a room save writes each one's room count. for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves. + for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill). for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record) @@ -5469,6 +5473,120 @@ describe('rooms endpoints', () => { expect((await get(detail, '999')).status).toBe(403) }) + describe('POST /unity_assets/baked/bulk', () => { + const URL = `${ORIGIN}/unity_assets/baked/bulk` + const post = (body: string) => + SELF.fetch(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + const A = '4f0c1e4a-2b77-4b16-9a3e-5dbcfd7f2865' + const B = '9a3e5dbc-fd7f-2865-8f0c-1e4a2b774b16' + const C = '22555d14-2918-43f3-94d4-da0c893d96c4' + + beforeAll(async () => { + await putUnityAsset(env.DB, { + UnityAssetId: A, + Target: 0, + Version: 227, + Filename: '2026-10-05/a0d83019-675d-41c0-8924-c238c5a12ee8', + Hash: 'R0SVMvrAW4n1TIF2lxaOAQZ5XXVJh7prEP6GpiLCrQo=', + }) + await putUnityAsset(env.DB, { + UnityAssetId: A, + Target: 2, + Version: 227, + Filename: '2026-10-05/quest-a', + Hash: 'quest-a-hash', + }) + // B was built twice for Windows. + await putUnityAsset(env.DB, { + UnityAssetId: B, + Target: 0, + Version: 3, + Filename: '2026-10-05/b-v3', + Hash: 'b-v3-hash', + }) + await putUnityAsset(env.DB, { + UnityAssetId: B, + Target: 0, + Version: 5, + Filename: '2026-10-05/b-v5', + Hash: 'b-v5-hash', + }) + }) + + it('answers a bare array of five-key entries for the ids asked, in order', async () => { + const res = await post(`target=0&version=227&id=${B}&id=${A}`) + expect(res.status).toBe(200) + const body = (await res.json()) as Array> + expect(body.map((e) => e.UnityAssetId)).toEqual([B, A]) + expect(Object.keys(body[1]!)).toEqual([ + 'UnityAssetId', + 'Target', + 'Version', + 'Filename', + 'Hash', + ]) + expect(body[1]).toEqual({ + UnityAssetId: A, + Target: 0, + Version: 227, + Filename: '2026-10-05/a0d83019-675d-41c0-8924-c238c5a12ee8', + Hash: 'R0SVMvrAW4n1TIF2lxaOAQZ5XXVJh7prEP6GpiLCrQo=', + }) + }) + + it('serves the build for the target asked, and leaves out assets with none', async () => { + // C has no build at all, so no blank row: the client downloads nothing for it either + // way, and a row with no Filename is the quieter failure. + const quest = (await ( + await post(`target=2&version=227&id=${A}&id=${C}&id=${B}`) + ).json()) as Array> + expect(quest).toEqual([ + { + UnityAssetId: A, + Target: 2, + Version: 227, + Filename: '2026-10-05/quest-a', + Hash: 'quest-a-hash', + }, + ]) + }) + + it('prefers the build at the requested version and falls back to the newest', async () => { + const exact = (await (await post(`target=0&version=3&id=${B}`)).json()) as Array<{ + Version: number + }> + expect(exact.map((e) => e.Version)).toEqual([3]) + // A version nothing was built at still answers the asset — its newest build. + const newest = (await (await post(`target=0&version=4&id=${B}`)).json()) as Array<{ + Version: number + }> + expect(newest.map((e) => e.Version)).toEqual([5]) + // No version at all: the newest. + const none = (await (await post(`target=0&id=${B}`)).json()) as Array<{ Version: number }> + expect(none.map((e) => e.Version)).toEqual([5]) + }) + + it('matches ids case-insensitively and accepts a comma-separated list', async () => { + const body = (await ( + await post(`target=0&version=227&id=${A.toUpperCase()},${B}`) + ).json()) as Array<{ + UnityAssetId: string + }> + expect(body.map((e) => e.UnityAssetId)).toEqual([A, B]) + }) + + it('answers [] for no ids, an unknown id, or a non-integer target', async () => { + expect(await (await post('target=0&version=227')).json()).toEqual([]) + expect(await (await post(`target=0&version=227&id=${C}`)).json()).toEqual([]) + expect(await (await post(`target=win&version=227&id=${A}`)).json()).toEqual([]) + expect(await (await post('')).json()).toEqual([]) + }) + }) + it('GET /openapi.json documents every route', async () => { const res = await SELF.fetch(`${ORIGIN}/openapi.json`) expect(res.status).toBe(200) @@ -5544,6 +5662,7 @@ describe('rooms endpoints', () => { 'POST /rooms/{roomId}/subrooms/{subRoomId}/data', 'POST /rooms/{roomId}/subrooms/{subRoomId}/move', 'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save', + 'POST /unity_assets/baked/bulk', 'PUT /rooms/{roomId}/accessibility', 'PUT /rooms/{roomId}/cloning', 'PUT /rooms/{roomId}/creator', diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 748e601..642295c 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -8,6 +8,7 @@ export { } from './enums' export * from './accounts-db' export * from './studio-unity-assets' +export * from './unity-assets-db' export * from './audit-db' export * from './clubs-db' export * from './d1-binds' diff --git a/packages/domain/src/unity-assets-db.ts b/packages/domain/src/unity-assets-db.ts new file mode 100644 index 0000000..da3a3c6 --- /dev/null +++ b/packages/domain/src/unity-assets-db.ts @@ -0,0 +1,119 @@ +/** + * Baked unity asset bundles — the blob references behind a room's asset-bundle download. + * + * `unity_asset` holds one row per BUILD of a unity asset: the asset's GUID, the `Target` + * it was built for (0 Windows, 2 Android/Quest — the same numbers a custom avatar's + * `unityAssetTarget` uses), its `Version`, and the storage blob the client downloads + * (`Filename`, a `/` path, and its base64 SHA-256 `Hash`). The client asks for + * these in bulk from `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a + * room — the step Player.log times as `AssetBundle_Download` — and downloads `Filename` + * from the CDN; a lookup that answers no row, or a row without a `Filename`, simply + * downloads nothing, with no error anywhere. + * + * This is a separate store from the Rec Room Studio builds in `studio_unity_asset_file` + * (see studio-unity-assets.ts), which the studio worker writes per platform and kind and + * which `GET /rooms/{id}` folds onto `CurrentSave.UnitySubAssets`. Nothing writes this + * table yet; it is the record the bulk lookup reads. + * + * The `rooms` worker owns the table and its migration — see + * apps/rooms/migrations/0024_unity_asset.sql. + */ + +import { bindPlaceholders, chunkForBinds } from './d1-binds' + +/** Schema DDL (mirror of apps/rooms/migrations/0024_unity_asset.sql) — tests apply this. */ +export const UNITY_ASSET_SCHEMA_DDL: string[] = [ + // A unity asset is built once per target, and may be rebuilt at a new version, so the + // three together name a row. `unity_asset_id` is stored lowercase: a GUID's case is not + // part of its identity and the client is not consistent about it. + `CREATE TABLE IF NOT EXISTS unity_asset ( + unity_asset_id TEXT NOT NULL, + target INTEGER NOT NULL, + version INTEGER NOT NULL, + filename TEXT NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (unity_asset_id, target, version) + )`, +] + +/** + * One baked bundle as the client reads it — its five-key DTO, in this order. An element of + * the bare array `POST /unity_assets/baked/bulk` answers. + */ +export interface BakedUnityAsset { + /** GUID. */ + UnityAssetId: string + /** 0 Windows, 2 Android/Quest. */ + Target: number + Version: number + /** The storage blob path the client downloads, `/`. */ + Filename: string + /** Base64 SHA-256 of the blob. */ + Hash: string +} + +interface UnityAssetRow { + unity_asset_id: string + target: number + version: number + filename: string + hash: string +} + +const toBakedUnityAsset = (row: UnityAssetRow): BakedUnityAsset => ({ + UnityAssetId: row.unity_asset_id, + Target: row.target, + Version: row.version, + Filename: row.filename, + Hash: row.hash, +}) + +/** Record a build. Replaces the row for the same (asset, target, version). */ +export async function putUnityAsset(db: D1Database, asset: BakedUnityAsset): Promise { + await db + .prepare( + `INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, filename, hash) + VALUES (?1, ?2, ?3, ?4, ?5)` + ) + .bind(asset.UnityAssetId.toLowerCase(), asset.Target, asset.Version, asset.Filename, asset.Hash) + .run() +} + +/** + * The baked bundles for `unityAssetIds` on `target` — one entry per asset that has one, in + * the order asked, assets with no build left out. An asset built more than once is served + * ONCE: the build at the requested `version` when there is one, otherwise the newest. The + * request's version is a preference, not a filter, because the one capture pairing the two + * (a request for version 3 answered with version 227) shows the reference did not require + * them to match, and an empty answer here is a room that silently loads nothing. + * + * Ids are matched case-insensitively and served as stored (lowercase). Nothing to ask + * about reads nothing. + */ +export async function getBakedUnityAssets( + db: D1Database, + unityAssetIds: string[], + target: number, + version: number +): Promise { + const ids = [...new Set(unityAssetIds.map((id) => id.toLowerCase()))] + if (ids.length === 0) return [] + const pages = await Promise.all( + chunkForBinds(ids, 2).map((chunk) => + db + .prepare( + `SELECT unity_asset_id, target, version, filename, hash FROM unity_asset + WHERE target = ?1 AND unity_asset_id IN (${bindPlaceholders(chunk, 2)}) + ORDER BY (version = ?2) DESC, version DESC` + ) + .bind(target, version, ...chunk) + .all() + ) + ) + // Rows arrive best-first per asset, so the first one seen for an id is the one to serve. + const best = new Map() + for (const row of pages.flatMap((page) => page.results)) { + if (!best.has(row.unity_asset_id)) best.set(row.unity_asset_id, toBakedUnityAsset(row)) + } + return ids.flatMap((id) => best.get(id) ?? []) +}