[studio] Complete editor uploads and queued cloud builds (#135)

* [studio] Complete editor uploads and queued cloud builds

* [studio] Protect unpublished source and remove unused notification RPC
This commit is contained in:
dukedotdev
2026-10-19 11:04:58 -04:00
committed by GitHub
parent 81c372135c
commit a3291bc9ce
30 changed files with 2553 additions and 142 deletions
+6 -2
View File
@@ -38,8 +38,12 @@ Reads are the exception: they carry no envelope at all. A paged read (the save h
same number, because the client's paged DTO and the reference disagree on the name.
`GET /rooms/{roomId}` puts a Studio build's main bundles on `CurrentSave.UnitySubAssets`
(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.
saves keep the arrays empty. `GET …/unityasset?unityAssetId=<guid>` returns the
editable source manifest's filename and hash, plus available baked-build metadata.
The current published source is public; other revisions require save-history
permissions. Without an asset ID, authorized save readers receive the latest
source, while other callers receive only the current published source. A save
with only baked bundles has no editable source and returns 404.
`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.
+20 -6
View File
@@ -192,8 +192,11 @@ export const SubRoomDataSaveResponseDto = z.object({
.string()
.nullable()
.describe('Base64 SHA-256 of `unityAsset` when that file is set; otherwise null'),
dataBlob: z.string(),
dataBlob: z.string().nullable(),
dataBlobHash: z.string().nullable().describe('Echoed from the request’s `SubRoomData.Hash`'),
persistenceVersion: z.int(),
omVersion: z.int(),
ugcSubVersion: z.int(),
savedByAccountId: z.int().nullable(),
savedOnPlatform: z
.int()
@@ -225,11 +228,11 @@ export const UnitySubAssetDto = z.object({
export const SubRoomDataSaveDto = z.object({
UnitySubAssets: z
.array(UnitySubAssetDto)
.describe('Main Studio bundles for this save; empty for a maker-pen save'),
.describe('Subasset dependencies, excluding the main bundle; empty when none'),
ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'),
SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'),
SubRoomId: z.int().describe('The owning subroom — re-pointed when a subroom is cloned'),
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'),
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'),
PersistenceVersion: z.int(),
OMVersion: z.int(),
@@ -439,7 +442,7 @@ export const RoomBoostCount = z
* A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name
* lookups answer an empty object rather than a 404 — the client reads that as "no room".
*/
export const RoomLookup = z.union([RoomDto, z.object({})])
export const RoomLookup = z.union([RoomDto, z.object({}), z.null()])
/** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */
export const MissingLookupParam = z
@@ -588,6 +591,14 @@ export const RoomEnvelope = z.object({
value: RoomDto.nullable(),
})
/** Result<T> consumed by Studio's v2 settings API. */
export const StudioRoomEnvelope = z.object({
Success: z.boolean(),
Error: z.string().nullable(),
Value: RoomDto.nullable(),
})
export const RoomRestrictionsEnvelope = z.union([RoomEnvelope, StudioRoomEnvelope])
/**
* What `POST /rooms/{roomId}/subrooms/{subRoomId}/data` answers: `value` carries BOTH the
* updated room and the save that was just created. Note `error` is NULL here, not the
@@ -796,6 +807,7 @@ export const BulkRoomsRequest = z.object({
* the fields actually posted are changed, and the names are matched case-insensitively.
*/
export const RestrictionsRequest = z.object({
ageRating: z.string().optional().describe('0 unset, 1 all ages, 2 ages 13 and up'),
supportsScreens: z.string().optional().describe('`True` / `False`'),
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
@@ -949,6 +961,8 @@ export const SaveSubRoomDataRequest = z.object({
.optional()
.describe('The save comment — a description of THIS revision, not the room’s description'),
PersistenceVersion: z.int().optional().describe('Recorded on the save and the subroom'),
OMVersion: z.int().optional().describe('Object-model format version; retained when omitted'),
UgcSubVersion: z.int().optional().describe('UGC format revision; retained when omitted'),
InventionUsage: z.string().optional().describe('Recorded on the subroom'),
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
AutoPublish: z
@@ -982,7 +996,7 @@ export const SubRoomDataSaveNoUnityAssetsDto = z.object({
SubRoomId: z.int(),
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'),
DataBlobHash: z.string().nullable(),
PersistenceVersion: z.int(),
OMVersion: z.int(),
@@ -1027,7 +1041,7 @@ export const SubRoomDataSaveDetailDto = z.object({
SubRoomId: z.int(),
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'),
DataBlobHash: z.string().nullable(),
PersistenceVersion: z.int(),
OMVersion: z.int(),
+153 -47
View File
@@ -16,6 +16,7 @@ import {
cloneSubRoom,
countRoomsByCreator,
createNotification,
createStudioRoom,
createSubRoom,
deleteExpiredRoomInvites,
deleteRoom,
@@ -46,7 +47,6 @@ import {
getRoomsByIds,
getSimilarRooms,
getStudioUnityAsset,
getSubRoom,
getSubRoomPermissions,
getSubRoomSaveById,
getSubRoomSaves,
@@ -159,6 +159,7 @@ import {
roomIdParam,
RoomLookup,
RoomPascalEnvelope,
RoomRestrictionsEnvelope,
RoomResultEnvelope,
RoomRoleDto,
RoomSaveEnvelope,
@@ -933,11 +934,15 @@ function toSaveResponse(save: Record<string, unknown>, target: number | null = n
unityAssetId: str(save.UnityAssetId),
unityAsset: baked?.filename ?? null,
unityAssetHash: baked?.hash ?? null,
dataBlob: str(save.DataBlob) ?? '',
dataBlob: str(save.DataBlob),
dataBlobHash: str(save.DataBlobHash),
persistenceVersion: num(save.PersistenceVersion) ?? 0,
omVersion: num(save.OMVersion) ?? 0,
ugcSubVersion: num(save.UgcSubVersion) ?? 0,
savedByAccountId: num(save.SavedByAccountId),
savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
savedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0),
savedOnDeviceClass:
save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0),
description: str(save.Description),
createdAt: str(save.CreatedAt) ?? '',
}
@@ -1014,13 +1019,14 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
? save.ReferencedUnityAssetIds
: [],
DataBlob: str(save.DataBlob) ?? '',
DataBlob: str(save.DataBlob),
DataBlobHash: str(save.DataBlobHash),
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
OMVersion: num(save.OMVersion) ?? 0,
SavedByAccountId: num(save.SavedByAccountId),
SavedOnPlatform: num(save.SavedOnPlatform) ?? 0,
SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
SavedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0),
SavedOnDeviceClass:
save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0),
Description: str(save.Description) ?? '',
ModerationState: num(save.ModerationState) ?? 0,
CreatedAt: str(save.CreatedAt) ?? '',
@@ -1028,6 +1034,17 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
}
}
/** A JSON authoring manifest is not interchangeable with a baked game bundle. */
function studioSource(
save: Record<string, unknown>
): { Filename: string; Hash: string | null } | null {
const value = save.StudioSourceAsset
if (typeof value !== 'object' || value === null) return null
const source = value as { Filename?: unknown; Hash?: unknown }
if (typeof source.Filename !== 'string' || source.Filename === '') return null
return { Filename: source.Filename, Hash: typeof source.Hash === 'string' ? source.Hash : null }
}
/**
* One save as `GET …/subrooms/:sid/saves/:saveId` answers it — the PascalCase row the
* `…/saves` list serves, NOT the camelCase `subRoomDataSave` the room save that created
@@ -1035,10 +1052,10 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
* values were all missing). Two things differ from the list row, both observed against
* the reference:
*
* - Each `UnitySubAssets` entry is `{ UnityAssetId, UnityAsset, UnityAssetHash }` — the
* stored `Filename`/`Hash` under the names the client's save-detail decoder reads —
* and the save's own main bundle is lifted to top-level `UnityAsset`/`UnityAssetHash`
* (the named target's, Windows when none was named; both null for a maker-pen save).
* - The save's main bundle is at top-level `UnityAsset`/`UnityAssetHash`. Only other
* bundles belong in `UnitySubAssets`, as `{ UnityAssetId, UnityAsset, UnityAssetHash }`.
* Concrete filenames use the named target, Windows when absent; the main fields
* are null for a maker-pen save.
* - No `Tags`, and `UnityAssetId` is always present (null when the save carried none).
*
* Built key by key like {@link toSaveWithoutUnityAssets}, for the same reason.
@@ -1047,11 +1064,18 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
const str = (v: unknown) => (typeof v === 'string' ? v : null)
const num = (v: unknown) => (typeof v === 'number' ? v : null)
const main = pickBakedBundle(save, target)
// The root bundle is loaded separately through UnityAsset. Listing it as a
// dependency makes Unity load the same serialized files twice (room error 8714).
// A detail response names concrete files, so default to Windows just as `main`
// does; it cannot carry both platform builds under the same asset GUID.
const rootId = str(save.UnityAssetId)?.toLowerCase()
const bundles = (Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []).filter(
(item): item is Record<string, unknown> =>
typeof item === 'object' &&
item !== null &&
(target === null || (item as { Target?: unknown }).Target === target)
(item as { Target?: unknown }).Target === (target ?? 0) &&
(typeof (item as { UnityAssetId?: unknown }).UnityAssetId !== 'string' ||
(item as { UnityAssetId: string }).UnityAssetId.toLowerCase() !== rootId)
)
return {
UnitySubAssets: bundles.map((row) => ({
@@ -1070,7 +1094,7 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
? save.ReferencedUnityAssetIds
: [],
DataBlob: str(save.DataBlob) ?? '',
DataBlob: str(save.DataBlob),
DataBlobHash: str(save.DataBlobHash),
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
OMVersion: num(save.OMVersion) ?? 0,
@@ -1121,7 +1145,9 @@ async function ownedRooms(c: Context<App>) {
async function ownedRoomsExcludingDorm(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const rooms = await getRoomsByCreator(c.env.DB, accountId)
const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) =>
canManageRoom(room, accountId)
)
return c.json(rooms.filter((r) => r.IsDorm !== true))
}
@@ -1278,10 +1304,42 @@ const app = new Hono<App>()
if (idParam) {
const id = firstId(idParam)
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
return c.json(room ?? {})
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ?? {})
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
}
)
// Studio creates an empty room directly, rather than cloning a game template.
.post(
'/rooms/create',
describeRoute({
tags: ['My rooms'],
summary: 'Create an empty Studio room',
security: AUTHED,
requestBody: form(CloneRoomRequest, 'Room name and optional ugcVersion (1 or 2)'),
responses: {
200: json(RoomDto, 'The private room with its empty Home subroom'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const body = await c.req.parseBody()
const name = typeof body.name === 'string' ? body.name.trim() : ''
const invalid = roomNameRejection(name, 'room name')
if (name === '' || invalid !== null)
return c.json({ error: invalid ?? 'A room name is required.' }, 400)
const version = Number(body.ugcVersion ?? 1)
if (version !== 1 && version !== 2) return c.json({ error: 'Unsupported UGC version.' }, 400)
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms)
return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403)
const room = await createStudioRoom(c.env.DB, name, accountId, version)
if (!room) return c.json({ error: 'A room with that name already exists.' }, 409)
return c.json(room)
}
)
@@ -1688,9 +1746,9 @@ const app = new Hono<App>()
'/rooms/ownedby/me',
describeRoute({
tags: ['My rooms'],
summary: 'Rooms the caller owns (excluding their dorm)',
summary: 'Rooms the caller owns or co-owns (excluding their dorm)',
description: [
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
'The caller’s created and accepted co-owned rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
'`createdby/me` for everything the account created. Accessibility is deliberately NOT',
'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike',
@@ -3553,8 +3611,9 @@ const app = new Hono<App>()
// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
.put(
'/rooms/:roomId{[0-9]+}/restrictions',
.on(
'PUT',
['/rooms/:roomId{[0-9]+}/restrictions', '/rooms/:roomId{[0-9]+}/v2/restrictions'],
describeRoute({
tags: ['Room settings'],
summary: 'Set a room’s platform/movement support flags',
@@ -3567,7 +3626,10 @@ const app = new Hono<App>()
parameters: [roomIdParam],
requestBody: form(RestrictionsRequest, 'The flags to change'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
200: json(
RoomRestrictionsEnvelope,
'The updated room; Studio v2 uses a PascalCase result envelope'
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
@@ -3584,7 +3646,7 @@ const app = new Hono<App>()
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const patch: Record<string, boolean> = {}
const patch: Record<string, boolean | number> = {}
for (const [key, value] of Object.entries(body)) {
const field = RESTRICTION_FIELDS[key.toLowerCase()]
if (field !== undefined && typeof value === 'string') {
@@ -3592,8 +3654,20 @@ const app = new Hono<App>()
}
}
const ageEntry = Object.entries(body).find(([key]) => key.toLowerCase() === 'agerating')
if (ageEntry !== undefined) {
const age = ageEntry[1]
if (typeof age !== 'string' || !/^[012]$/.test(age)) {
if (c.req.path.endsWith('/v2/restrictions'))
return c.json({ Success: false, Error: 'Invalid age rating.', Value: null })
return roomEnvelope(c, null, 'Invalid age rating.')
}
patch.AgeRating = Number(age)
}
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
await pushRoomUpdate(c, accountId, updated)
if (c.req.path.endsWith('/v2/restrictions'))
return c.json({ Success: true, Error: null, Value: updated })
return roomEnvelope(c, updated)
}
)
@@ -3925,7 +3999,14 @@ const app = new Hono<App>()
// `TotalResults` counts the whole history, not the page — that is what the client
// pages against.
return c.json({
Results: page.map(toSaveWithoutUnityAssets),
Results: page.map((save) => {
const result = toSaveWithoutUnityAssets(save)
// Archived game bundles have no editable Unity project. Studio can still
// load their Maker Pen data; don't make it fetch a nonexistent source manifest.
if (c.req.header('user-agent')?.startsWith('RRStudio') && !studioSource(save))
result.UnityAssetId = null
return result
}),
TotalResults: saves.length,
TotalCount: saves.length,
})
@@ -4003,24 +4084,22 @@ const app = new Hono<App>()
}
)
// Metadata for the unity asset on one of this subroom's saves. The editor calls
// this after a build; the game reads the same bundles from `CurrentSave` and then
// downloads `filename` from the CDN. Bare object, no envelope, no auth — the room
// document already names the files.
// The authoring manifest on a save. Its JSON source filename is independent of
// the baked game bundles; a baked-only archive has no editable source to return.
.get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset',
describeRoute({
tags: ['Subrooms'],
summary: 'The unity asset attached to a subroom save',
description: [
'The baked Windows and Android bundles stored for `unityAssetId`, when that id is',
'The editable JSON source manifest and any baked bundles stored for `unityAssetId`, when that id is',
'on a save of this subroom. A bare object: `unityAssetId`, `createdByAccountId`,',
'`bakedUnityAssets` (one entry per main bundle), plus `filename` and `hash` for the',
'Windows bundle. Bytes are `GET /unityasset/{filename}` on the CDN.',
'authoring manifest. Source JSON is `GET /roomunityasset/{filename}` on the CDN; baked bundles remain separate.',
'',
'`target` is 0 for Windows and 2 for Android/Quest. Stripped bundles are stored and',
'not listed. An unknown room, subroom, or asset is a 404. No auth: `GET /rooms/{id}`',
'already includes the same filenames on `CurrentSave`.',
'Stripped bundles are not listed. The published current save is public. Other revisions require the same',
'authentication and permissions as save history. Without an asset ID, authorized',
'readers receive the latest source; other callers receive only the published source.',
].join(' '),
parameters: [
roomIdParam,
@@ -4029,6 +4108,8 @@ const app = new Hono<App>()
],
responses: {
200: json(UnityAssetWithSourceDto, 'The unity asset and its baked bundles'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room, subroom, or asset on that subroom' },
},
}),
@@ -4036,27 +4117,40 @@ const app = new Hono<App>()
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const unityAssetId = c.req.query('unityAssetId') ?? ''
if (unityAssetId === '') return c.notFound()
const sub = await getSubRoom(c.env.DB, roomId, subRoomId)
if (!sub) return c.notFound()
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
const room = await getRoomById(c.env.DB, roomId)
const sub = room && findSubRoom(room, subRoomId)
if (!room || !sub) return c.notFound()
const current = sub.CurrentSave
let save =
typeof current === 'object' && current !== null
? (current as Record<string, unknown>)
: undefined
const accountId = await authedAccountId(c)
const canReadHistory = accountId !== null && (await canReadSaves(c, room, roomId, accountId))
if (canReadHistory) {
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
save =
unityAssetId === ''
? saves.find((row) => studioSource(row) !== null)
: saves.find((row) => row.UnityAssetId === unityAssetId)
} else if (unityAssetId !== '' && save?.UnityAssetId !== unityAssetId) {
if (accountId === null) return unauthorized(c)
return c.body(null, 403)
}
if (!save) return c.notFound()
const asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
if (!asset) return c.notFound()
// Main bundles, Windows first; the Windows one (or the first there is) supplies
// the top-level `filename`/`hash`.
const mains = bakedUnityAssets(asset.builds)
const source = mains.find((build) => build.Target === 0) ?? mains[0]
const source = studioSource(save)
if (!source) return c.notFound()
const asset = await getStudioUnityAsset(c.env.DB, String(save.UnityAssetId))
// Baked bundles are optional until compilation; the source exists before a build.
const mains = bakedUnityAssets(asset?.builds ?? [])
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
return c.json({
unityAssetId: asset.unityAssetId,
createdByAccountId: asset.createdByAccountId || savedBy,
unityAssetId: String(save.UnityAssetId),
createdByAccountId: asset?.createdByAccountId || savedBy,
bakedUnityAssets: mains.map((build) => ({
unityAssetId: build.UnityAssetId,
target: build.Target,
@@ -4132,8 +4226,11 @@ const app = new Hono<App>()
RoomData?: { Filename?: string; Hash?: string | null }
SubRoomData?: { Filename?: string; Hash?: string | null }
UnityAssetId?: string | null
UnityAsset?: { Filename?: string; Hash?: string | null }
Description?: string
PersistenceVersion?: number
OMVersion?: number
UgcSubVersion?: number
InventionUsage?: string
AutoPublish?: boolean
}
@@ -4145,10 +4242,19 @@ const app = new Hono<App>()
roomDataFilename: body.RoomData?.Filename,
roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined,
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
unitySource:
typeof body.UnityAsset?.Filename === 'string' && body.UnityAsset.Filename !== ''
? {
Filename: body.UnityAsset.Filename,
Hash: typeof body.UnityAsset.Hash === 'string' ? body.UnityAsset.Hash : null,
}
: undefined,
autoPublish: body.AutoPublish === true,
description: typeof body.Description === 'string' ? body.Description : undefined,
persistenceVersion:
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
omVersion: typeof body.OMVersion === 'number' ? body.OMVersion : undefined,
ugcSubVersion: typeof body.UgcSubVersion === 'number' ? body.UgcSubVersion : undefined,
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
})
if (!result) {
+431 -17
View File
@@ -3744,6 +3744,9 @@ describe('rooms endpoints', () => {
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
expect(saved.value.subRoomDataSave).toEqual({
persistenceVersion: 41,
omVersion: 0,
ugcSubVersion: 0,
subRoomDataSaveId: expect.any(Number),
subRoomId: 2,
unityAssetId: null,
@@ -6076,6 +6079,7 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/bans/history',
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
'GET /rooms/{roomId}/boosts/{playerId}/count',
'GET /rooms/{roomId}/experience',
'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me',
@@ -6089,6 +6093,7 @@ describe('rooms endpoints', () => {
'GET /roomserver/rooms/createdby/me',
'GET /showcase/{playerId}',
'POST /rooms/bulk',
'POST /rooms/create',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/experience',
@@ -6119,6 +6124,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/v2/restrictions',
'PUT /rooms/{roomId}/warning',
])
@@ -6135,7 +6141,7 @@ describe('rooms endpoints', () => {
// `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
// table must not fail the room read.
describe('studio room bundles', () => {
const ASSET = '11111111-2222-4333-8444-555555555555'
const ASSET = 'aa111111-2222-4333-8444-555555555555'
const winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main')
const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
@@ -6157,7 +6163,12 @@ describe('studio room bundles', () => {
Tags: [],
ModerationState: 0,
CreatedAt: '2026-10-03T00:00:00.000Z',
...(unityAssetId ? { UnityAssetId: unityAssetId } : {}),
...(unityAssetId
? {
UnityAssetId: unityAssetId,
StudioSourceAsset: { Filename: 'source-manifest.json', Hash: 'source-hash' },
}
: {}),
})
async function reset() {
@@ -6294,20 +6305,24 @@ describe('studio room bundles', () => {
{ unityAssetId: ASSET, target: 0, version: 1, filename: winName },
{ unityAssetId: ASSET, target: 2, version: 1, filename: androidName },
],
filename: winName,
hash: winHash,
filename: 'source-manifest.json',
hash: 'source-hash',
})
expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(404)
expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(200)
expect(
(
await SELF.fetch(
`${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000`
`${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000`,
{ headers: await bearer('1') }
)
).status
).toBe(404)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`))
.status
(
await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`, {
headers: await bearer('1'),
})
).status
).toBe(404)
const detail = await SELF.fetch(
@@ -6316,15 +6331,12 @@ describe('studio room bundles', () => {
)
expect(detail.status).toBe(200)
// The detail lifts the Windows bundle to the top level when no target is named, and
// names every bundle the client's way — `UnityAsset`/`UnityAssetHash`, not the stored
// `Filename`/`Hash`.
// lists only dependencies in UnitySubAssets. Repeating the root there makes the
// game load it twice and Unity rejects the second load with room error 8714.
expect(await detail.json()).toMatchObject({
UnityAsset: winName,
UnityAssetHash: winHash,
UnitySubAssets: [
{ UnityAssetId: ASSET, UnityAsset: winName, UnityAssetHash: winHash },
{ UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash },
],
UnitySubAssets: [],
})
const questDetail = await SELF.fetch(
@@ -6334,9 +6346,7 @@ describe('studio room bundles', () => {
expect(await questDetail.json()).toMatchObject({
UnityAsset: androidName,
UnityAssetHash: androidHash,
UnitySubAssets: [
{ UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash },
],
UnitySubAssets: [],
})
const light = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/no_unity_assets`, {
@@ -6358,6 +6368,35 @@ describe('studio room bundles', () => {
})
})
it('never loads the main bundle again as a dependency, including mixed-case GUIDs', async () => {
await reset()
await storeFiles()
await env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data, '$.UnityAssetId', ?1) WHERE sub_room_id=99011"
)
.bind(ASSET.toUpperCase())
.run()
const row = await env.DB.prepare(
'SELECT current_save_id FROM subroom WHERE sub_room_id=99011'
).first<{ current_save_id: number }>()
for (const [query, filename] of [
['', winName],
['?unityAssetTarget=0', winName],
['?unityAssetTarget=2', androidName],
['?unityAssetTarget=1', null],
] as const) {
const response = await SELF.fetch(
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${row!.current_save_id}${query}`,
{ headers: await bearer('1') }
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
UnityAsset: filename,
UnitySubAssets: [],
})
}
})
// A FIRST-PARTY room's bundle — captured from the official game and loaded by hand
// under its `unity/<roomId>/` layout — shares `unity_asset` with Studio builds but is
// not UGC, and must not be folded onto the save: the 2025 client handed it inline
@@ -6682,3 +6721,378 @@ describe('Studio baked Unity asset downloads', () => {
expect(await (await bulk({ UnityAssetIds: [mainId] })).json()).toEqual([])
})
})
describe('Studio room management contracts', () => {
it('lists private co-owned rooms, but excludes hosts and pending invites', async () => {
for (const [id, role, invited] of [
[91001, 30, 0],
[91002, 10, 0],
[91003, 0, 30],
]) {
await seedRoomWithSubRooms(env.DB, {
RoomId: id,
Name: `StudioRole${id}`,
CreatorAccountId: 999,
Accessibility: 0,
Roles: [{ AccountId: 880, Role: role, InvitedRole: invited }],
SubRooms: [],
})
}
const rooms = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('880') })
).json()) as Array<{ RoomId: number }>
expect(rooms.map((r) => r.RoomId)).toEqual([91001])
expect(
await (
await SELF.fetch(`${ORIGIN}/rooms/createdby/me`, { headers: await bearer('880') })
).json()
).toEqual([])
})
it('returns null for Studio name validation and preserves the legacy empty object', async () => {
expect(
await (await SELF.fetch(`${ORIGIN}/rooms?name=StudioUnusedName&include=525581`)).json()
).toBeNull()
expect(await (await SELF.fetch(`${ORIGIN}/rooms?name=StudioUnusedName`)).json()).toEqual({})
})
it('creates a private empty R2 room and rejects a case-insensitive duplicate', async () => {
const create = async (name: string, auth = true) =>
SELF.fetch(`${ORIGIN}/rooms/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(auth ? await bearer('880') : {}),
},
body: new URLSearchParams({ name, ugcVersion: '2' }).toString(),
})
expect((await create('StudioFreshRoom', false)).status).toBe(401)
const response = await create('StudioFreshRoom')
expect(response.status).toBe(200)
const room = (await response.json()) as Room
expect(room).toMatchObject({
Name: 'StudioFreshRoom',
UgcVersion: 2,
Accessibility: 0,
CreatorAccountId: 880,
Tags: [],
LoadScreens: [],
RestrictedCircuitsAllowListNames: [],
PublishedAt: null,
})
expect(room.SubRooms).toEqual([
expect.objectContaining({
Name: 'Home',
CreatorAccountId: 880,
CurrentSave: expect.objectContaining({ DataBlob: null, OMVersion: 198 }),
UnitySceneId: '5d4e40d8-f289-4295-a6e1-4f907835007d',
}),
])
const added = await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) },
body: 'name=Second',
})
expect(added.status).toBe(200)
const lookup = await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}?include=525581`)
const refreshed = await lookup.json<
Room & { SubRooms: Array<{ SubRoomId: number; CurrentSave: unknown }> }
>()
expect(refreshed.SubRooms).toHaveLength(2)
for (const sub of refreshed.SubRooms) {
expect(sub.CurrentSave).toMatchObject({ DataBlob: null, OMVersion: 198 })
}
const initial = refreshed.SubRooms[0]!.CurrentSave as { SubRoomDataSaveId: number }
const subRoomId = Number(refreshed.SubRooms[0]!.SubRoomId)
const initialSave = await SELF.fetch(
`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms/${subRoomId}/saves/${initial.SubRoomDataSaveId}`,
{ headers: await bearer('880') }
)
expect(initialSave.status).toBe(200)
expect(await initialSave.json()).toMatchObject({ DataBlob: null, OMVersion: 198 })
for (const fields of [{ OMVersion: 197, UgcSubVersion: 3 }, {}]) {
const saved = await SELF.fetch(
`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms/${subRoomId}/data`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('880')) },
body: JSON.stringify({ ...fields, AutoPublish: true }),
}
)
expect(saved.status).toBe(200)
const updated = await (
await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}?include=525581`)
).json<Room & { SubRooms: Array<{ CurrentSave: unknown }> }>()
expect(updated.SubRooms[0]!.CurrentSave).toMatchObject({ OMVersion: 197, UgcSubVersion: 3 })
}
expect((await create('studiofreshroom')).status).toBe(409)
expect((await create('bad name')).status).toBe(400)
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}`, {
method: 'DELETE',
headers: await bearer('880'),
})
).status
).toBe(200)
})
it('keeps fresh R1 rooms owned and their new subrooms visible to the Studio picker', async () => {
const response = await SELF.fetch(`${ORIGIN}/rooms/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) },
body: new URLSearchParams({ name: 'StudioFreshR1', ugcVersion: '1' }).toString(),
})
expect(response.status).toBe(200)
const created = await response.json<Room>()
const roomId = Number(created.RoomId)
expect(created.Roles).toEqual([expect.objectContaining({ AccountId: 880, Role: 255 })])
const added = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) },
body: 'name=Second',
})
expect(added.status).toBe(200)
const lookup = await SELF.fetch(`${ORIGIN}/rooms?name=StudioFreshR1&include=525581`)
const room = await lookup.json<Room & { SubRooms: Array<{ SubRoomId: number }> }>()
expect(room.SubRooms).toEqual([
expect.objectContaining({
Name: 'Home',
CurrentSave: null,
UnitySceneId: 'a75f7547-79eb-47c6-8986-6767abcb4f92',
}),
expect.objectContaining({
Name: 'Second',
CurrentSave: null,
UnitySceneId: 'a75f7547-79eb-47c6-8986-6767abcb4f92',
}),
])
for (const path of ['/rooms/ownedby/me', '/rooms/createdby/me']) {
const rooms = await (
await SELF.fetch(ORIGIN + path, { headers: await bearer('880') })
).json<Room[]>()
expect(rooms.some((entry) => entry.RoomId === created.RoomId)).toBe(true)
}
const history = await SELF.fetch(
`${ORIGIN}/rooms/${roomId}/subrooms/${room.SubRooms[0]!.SubRoomId}/saves/no_unity_assets`,
{ headers: await bearer('880') }
)
expect(history.status).toBe(200)
expect(await history.json()).toMatchObject({ Results: [] })
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/${roomId}`, {
method: 'DELETE',
headers: await bearer('880'),
})
).status
).toBe(200)
})
it('saves the Studio age rating through v2 restrictions with ownership checks', async () => {
const before = await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json<Room>()
const update = async (body: string, account?: string) =>
SELF.fetch(`${ORIGIN}/rooms/2/v2/restrictions`, {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(account ? await bearer(account) : {}),
},
body,
})
expect((await update('ageRating=1')).status).toBe(401)
expect((await update('ageRating=1', '999')).status).toBe(403)
const result = await update('ageRating=1&supportsScreens=True&supportsWalkVR=False', '1')
expect(result.status).toBe(200)
expect(await result.json()).toMatchObject({
Success: true,
Error: null,
Value: {
AgeRating: 1,
SupportsScreens: true,
SupportsWalkVR: false,
LoadScreens: before.LoadScreens,
},
})
const invalid = await update('ageRating=99', '1')
expect(await invalid.json()).toEqual({
Success: false,
Error: 'Invalid age rating.',
Value: null,
})
expect((await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json<Room>()).AgeRating).toBe(1)
await update('ageRating=2&supportsWalkVR=True', '1')
})
it('does not advertise an archived baked asset as editable source', async () => {
const id = '11111111-2222-4333-8444-555555555555'
await seedRoomWithSubRooms(env.DB, {
RoomId: 91004,
Name: 'StudioArchivedGame',
CreatorAccountId: 880,
Roles: [],
SubRooms: [
{
SubRoomId: 91004,
Name: 'Home',
CurrentSave: {
UnityAssetId: id,
DataBlob: 'original-game.room',
SavedOnPlatform: 0,
SavedOnDeviceClass: 2,
SavedByAccountId: 880,
CreatedAt: '2026-01-01T00:00:00Z',
},
},
],
})
const headers = await bearer('880')
const url = `${ORIGIN}/rooms/91004/subrooms/91004/saves/no_unity_assets`
const studio = (await (
await SELF.fetch(url, { headers: { ...headers, 'User-Agent': 'RRStudio' } })
).json()) as { Results: Array<Record<string, unknown>> }
expect(studio.Results[0]).toMatchObject({ UnityAssetId: null, DataBlob: 'original-game.room' })
const legacy = (await (await SELF.fetch(url, { headers })).json()) as typeof studio
expect(legacy.Results[0]!.UnityAssetId).toBe(id)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/91004/subrooms/91004/unityasset?unityAssetId=${id}`))
.status
).toBe(404)
const original = (await (await SELF.fetch(`${ORIGIN}/rooms/91004`)).json()) as {
SubRooms: Array<{ CurrentSave: Record<string, unknown> }>
}
expect(original.SubRooms[0]!.CurrentSave.UnityAssetId).toBe(id)
})
it('persists source manifests and returns them independently of baked builds', async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 91005,
Name: 'StudioSourceRoom',
CreatorAccountId: 880,
Roles: [],
SubRooms: [{ SubRoomId: 91005, Name: 'Home', CurrentSave: null }],
})
const id = '22222222-3333-4444-8555-666666666666'
const saved = await SELF.fetch(`${ORIGIN}/rooms/91005/subrooms/91005/data`, {
method: 'POST',
headers: { ...(await bearer('880')), 'Content-Type': 'application/json' },
body: JSON.stringify({
UnityAssetId: id,
UnityAsset: { Filename: 'manifest.json', Hash: 'source-hash' },
SubRoomData: { Filename: 'maker.room' },
AutoPublish: true,
}),
})
expect(saved.status).toBe(200)
const source = await (
await SELF.fetch(`${ORIGIN}/rooms/91005/subrooms/91005/unityasset?unityAssetId=${id}`)
).json()
expect(source).toMatchObject({
unityAssetId: id,
filename: 'manifest.json',
hash: 'source-hash',
bakedUnityAssets: [],
})
const history = (await (
await SELF.fetch(`${ORIGIN}/rooms/91005/subrooms/91005/saves/no_unity_assets`, {
headers: { ...(await bearer('880')), 'User-Agent': 'RRStudio' },
})
).json()) as { Results: Array<Record<string, unknown>> }
expect(history.Results[0]).toMatchObject({
UnityAssetId: id,
SavedOnPlatform: null,
SavedOnDeviceClass: null,
})
})
})
describe('Studio source revision permissions', () => {
it('keeps drafts and historical source behind save-history permissions', async () => {
const roomId = 987650
await seedRoomWithSubRooms(env.DB, {
RoomId: roomId,
Name: 'PrivateStudioDraft',
CreatorAccountId: 987650,
Accessibility: 0,
Roles: [{ AccountId: 987651, Role: 30 }],
SubRooms: [{ SubRoomId: roomId, Name: 'Home', CurrentSave: null }],
})
const url = `${ORIGIN}/rooms/${roomId}/subrooms/${roomId}/unityasset`
const save = async (id: string, filename: string, autoPublish: boolean) => {
const response = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${roomId}/data`, {
method: 'POST',
headers: { ...(await bearer(String(roomId))), 'Content-Type': 'application/json' },
body: JSON.stringify({
UnityAssetId: id,
UnityAsset: { Filename: filename, Hash: 'source-hash' },
SubRoomData: { Filename: 'draft.room' },
AutoPublish: autoPublish,
}),
})
expect(response.status).toBe(200)
}
const oldId = '11111111-3333-4444-8555-666666666666'
const currentId = '22222222-3333-4444-8555-666666666666'
const draftId = '33333333-3333-4444-8555-666666666666'
await save(oldId, 'old.json', true)
await save(currentId, 'published.json', true)
await save(draftId, 'draft.json', false)
for (const headers of [{}, await bearer('987652')]) {
for (const query of ['', `?unityAssetId=${currentId}`]) {
const response = await SELF.fetch(url + query, { headers })
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ filename: 'published.json' })
}
}
for (const id of [draftId, oldId]) {
expect((await SELF.fetch(url + `?unityAssetId=${id}`)).status).toBe(401)
expect(
(await SELF.fetch(url + `?unityAssetId=${id}`, { headers: await bearer('987652') })).status
).toBe(403)
for (const account of ['987650', '987651']) {
const response = await SELF.fetch(url + `?unityAssetId=${id}`, {
headers: await bearer(account),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
filename: id === draftId ? 'draft.json' : 'old.json',
})
}
}
for (const account of ['987650', '987651']) {
const response = await SELF.fetch(url, { headers: await bearer(account) })
expect(await response.json()).toMatchObject({ filename: 'draft.json' })
}
// A reused asset ID must not make a newer draft public or hide it from its owner.
await save(currentId, 'replacement-draft.json', false)
const publicSource = await SELF.fetch(url + `?unityAssetId=${currentId}`)
expect(await publicSource.json()).toMatchObject({ filename: 'published.json' })
const ownerSource = await SELF.fetch(url + `?unityAssetId=${currentId}`, {
headers: await bearer('987650'),
})
expect(await ownerSource.json()).toMatchObject({ filename: 'replacement-draft.json' })
})
it('does not expose an unpublished first revision through default lookup', async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 987660,
Name: 'UnpublishedStudioSource',
CreatorAccountId: 987660,
Roles: [],
SubRooms: [{ SubRoomId: 987660, Name: 'Home', CurrentSave: null }],
})
const url = `${ORIGIN}/rooms/987660/subrooms/987660`
const saved = await SELF.fetch(url + '/data', {
method: 'POST',
headers: { ...(await bearer('987660')), 'Content-Type': 'application/json' },
body: JSON.stringify({
UnityAssetId: '44444444-3333-4444-8555-666666666666',
UnityAsset: { Filename: 'first-draft.json', Hash: null },
AutoPublish: false,
}),
})
expect(saved.status).toBe(200)
expect((await SELF.fetch(url + '/unityasset')).status).toBe(404)
expect(
(await SELF.fetch(url + '/unityasset', { headers: await bearer('987661') })).status
).toBe(404)
const source = await SELF.fetch(url + '/unityasset', { headers: await bearer('987660') })
expect(source.status).toBe(200)
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
})
})