mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user