[rooms] support for mitm

This commit is contained in:
Devin Zuczek
2026-10-05 17:07:42 -04:00
parent 032e37733d
commit 4e3301ade8
7 changed files with 350 additions and 0 deletions
+3
View File
@@ -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=<guid>&id=<guid>`) 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
@@ -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 `<date>/<guid>` 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=<guid>&id=<guid>`) 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)
);
+29
View File
@@ -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=<guid>&id=<guid>`); 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, `<date>/<guid>`'),
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. */
+56
View File
@@ -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<App>()
})
)
// 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<string, string | string[] | File | File[]>)
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',
+119
View File
@@ -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<string, unknown>)
@@ -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<Record<string, unknown>>
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<Record<string, unknown>>
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',
+1
View File
@@ -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'
+119
View File
@@ -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 `<date>/<guid>` 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, `<date>/<guid>`. */
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<void> {
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<BakedUnityAsset[]> {
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<UnityAssetRow>()
)
)
// Rows arrive best-first per asset, so the first one seen for an id is the one to serve.
const best = new Map<string, BakedUnityAsset>()
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) ?? [])
}