[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
+8
View File
@@ -1111,6 +1111,14 @@ export const SanitizeRequest = z.object({
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
/** Studio sends repeated Values form fields and consumes this result envelope. */
export const BulkAllowedRequest = z.object({ Values: z.array(z.string()) })
export const BulkAllowedResponse = z.object({
Success: z.boolean(),
Error: z.string().nullable(),
Value: z.object({ ArePure: z.array(z.boolean()) }).nullable(),
})
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
KeepsakeFeatureEnabled: z.boolean(),
+41
View File
@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { validateStudioBuildValidationToken } from '@repo/jwt'
import charadesAprilWords from '../../static/charades-april.json'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
@@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareString,
BulkAllowedRequest,
BulkAllowedResponse,
form,
idParam,
IsPureResponse,
json,
@@ -144,6 +149,42 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}
)
// Studio checks all component text in one request, preserving duplicates and order.
.post(
'/api/sanitize/v2/isAllowed/bulk',
describeRoute({
tags: ['Gameplay'],
summary: 'Check Studio component text in bulk',
description:
'Checks repeated Values form fields using the existing profanity policy. ArePure matches the input order, including duplicate and empty strings. Accepts an account token or a dedicated Studio build-validation credential with no account identity.',
security: AUTHED,
requestBody: form(BulkAllowedRequest, 'Repeated Values fields'),
responses: {
200: json(BulkAllowedResponse, 'Ordered results in a Studio result envelope'),
400: json(BulkAllowedResponse, 'Invalid form values'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
if (
(await authedId(c)) === null &&
!(await validateStudioBuildValidationToken(c.req.raw, await c.env.JWT_SECRET.get()))
)
return unauthorized(c)
const body = await c.req.parseBody({ all: true }).catch(() => null)
const field = body?.Values
const values = field === undefined ? [] : Array.isArray(field) ? field : [field]
if (body === null || values.some((value) => typeof value !== 'string')) {
return c.json({ Success: false, Error: 'Values must be strings.', Value: null }, 400)
}
return c.json({
Success: true,
Error: null,
Value: { ArePure: values.map((value) => !containsSwears(value as string)) },
})
}
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
+54
View File
@@ -4756,6 +4756,59 @@ describe('public endpoints', () => {
})
})
describe('POST /api/sanitize/v2/isAllowed/bulk', () => {
const bulk = async (body: URLSearchParams | FormData, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v2/isAllowed/bulk`, {
method: 'POST',
headers: authed ? await bearer('42') : {},
body,
})
test('401s without a token', async () => {
expect((await bulk(new URLSearchParams('Values=hello'), false)).status).toBe(401)
})
test('preserves repeated values, order, empty text and duplicates in the Studio envelope', async () => {
const body = new URLSearchParams()
for (const text of ['MakerRoom', 'fuck this', '', 'Scunthorpe', 'MakerRoom', 'sh1t'])
body.append('Values', text)
const res = await bulk(body)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false, true, true, true, false] },
})
})
test('accepts a single multipart value and an empty batch', async () => {
const body = new FormData()
body.append('Values', 'a$$hole')
expect(await (await bulk(body)).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [false] },
})
expect(await (await bulk(new URLSearchParams())).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [] },
})
})
test('rejects file values rather than marking them clean', async () => {
const body = new FormData()
body.append('Values', new Blob(['hello']), 'text.txt')
const res = await bulk(body)
expect(res.status).toBe(400)
expect(await res.json()).toEqual({
Success: false,
Error: 'Values must be strings.',
Value: null,
})
})
})
describe('POST /api/sanitize/v1/isPure', () => {
const isPure = async (Value?: string, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
@@ -10343,6 +10396,7 @@ describe('openapi', () => {
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/sanitize/v2/isAllowed/bulk',
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
@@ -0,0 +1,52 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { generateStudioBuildValidationToken, validateAndGetAccountId } from '@repo/jwt'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
})
it('allows build validation credentials to check text without granting an account identity', async () => {
const token = await generateStudioBuildValidationToken('123456', 'test-key')
const headers = { Authorization: 'Bearer ' + token }
expect(
await validateAndGetAccountId(new Request('https://example.com', { headers }), 'test-key')
).toBeNull()
const response = await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers,
body: new URLSearchParams('Values=hello&Values=fuck'),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false] },
})
expect(
(
await SELF.fetch('https://example.com/api/PlayerCheer/v1/create', {
method: 'POST',
headers,
body: new URLSearchParams(),
})
).status
).toBe(401)
})
it('rejects an invalid build validation signature', async () => {
const token = await generateStudioBuildValidationToken('123456', 'wrong-key')
expect(
(
await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: new URLSearchParams('Values=hello'),
})
).status
).toBe(401)
})
+13 -2
View File
@@ -304,7 +304,9 @@ async function authedId(c: Context<App>): Promise<number | null> {
}
/** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
if (role === 'screenshare') return true
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
}
@@ -314,7 +316,11 @@ function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
*/
async function roleAnswer(c: Context<App>, role: 'developer' | 'moderator', id: string) {
async function roleAnswer(
c: Context<App>,
role: 'developer' | 'moderator' | 'screenshare',
id: string
) {
logger.info(`${role} role lookup`, { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
@@ -1708,6 +1714,11 @@ const app = new Hono<App>()
roleFilterAnswer(c, 'moderator')
)
// Studio queries each room owner's screenshare role before validating video players.
.get('/role/screenshare/:id', describeRoute(roleLookup('screenshare')), (c) =>
roleAnswer(c, 'screenshare', c.req.param('id'))
)
// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
// Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing
+8 -6
View File
@@ -286,16 +286,18 @@ export const RestrictionDto = z.object({
DisplayReason: z.string().nullable().describe('Reason shown to the player. Free text'),
})
export function roleLookup(role: 'developer' | 'moderator') {
export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
return {
tags: ['Roles'],
summary: `Whether a player has the ${role} role`,
description:
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
`different answer shape — which of several players hold the role, as an array.`,
role === 'screenshare'
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
`different answer shape — which of several players hold the role, as an array.`,
parameters: [
{
name: 'id',
@@ -1821,6 +1821,22 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('')
})
test('GET /role/screenshare/:id matches the token feature gate for existing accounts', async () => {
for (const id of [42, 77]) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
}
})
test('GET /role/screenshare/:id does not grant a role to unknown accounts', async () => {
for (const id of ['999999', 'nonsense']) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(404)
expect(await res.text()).toBe('')
}
})
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
expect(res.status).toBe(200)
@@ -2032,6 +2048,7 @@ describe('auth worker routes', () => {
'GET /role/developer/{id}',
'GET /role/moderator',
'GET /role/moderator/{id}',
'GET /role/screenshare/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids',
+18 -4
View File
@@ -353,6 +353,22 @@ const app = new Hono<App>()
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// Editable Unity source manifests. Baked bundles are served separately.
.get(
'/roomunityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Download a Studio authoring manifest',
parameters: [keyParam('filename', 'Uploaded JSON source manifest', true)],
responses: assetResponses('The source manifest'),
}),
async (c) => {
const name = c.req.param('filename')
if (name.includes('..')) return c.body(null, 400)
return serveAsset(c, `roomunityasset/${name}`)
}
)
// Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// `unity_asset.filename` records it. Only a recorded filename is served, so this
@@ -426,16 +442,14 @@ async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env.DB, name)
if (!key) return c.notFound()
return serveAsset(c, key)
return serveAsset(c, key ?? `unityasset/${name}`)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env.DB, name)
if (!key) return new Response(null, { status: 404 })
const head = await env.CDN_ASSETS.head(key)
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 })
const headers = new Headers()
+25
View File
@@ -362,6 +362,7 @@ describe('cdn endpoints', () => {
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /roomunityasset/{filename}',
'GET /sigs/{sigName}',
'GET /unityasset/{filename}',
'HEAD /unityasset/{filename}',
@@ -377,3 +378,27 @@ describe('cdn endpoints', () => {
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
test('Studio authoring source manifests and source files remain distinct from baked bundles', async () => {
await env.CDN_ASSETS.put(
'roomunityasset/studio-source-fixture.json',
'{"Version":3,"Files":[]}',
{ httpMetadata: { contentType: 'application/json' } }
)
const source = await exports.default.fetch(`${ORIGIN}/roomunityasset/studio-source-fixture.json`)
expect(source.status).toBe(200)
expect(await source.json()).toEqual({ Version: 3, Files: [] })
await env.CDN_ASSETS.put('unityasset/studio-source-fixture.txt', 'source data')
const file = await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`)
expect(await file.text()).toBe('source data')
expect(
(
await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`, {
method: 'HEAD',
})
).status
).toBe(200)
expect((await exports.default.fetch(`${ORIGIN}/roomunityasset/missing-source.json`)).status).toBe(
404
)
})
+33
View File
@@ -31,6 +31,7 @@ const RS = '\u001e'
interface SocketState {
connectionId: string
handshakeDone: boolean
channels?: string[]
/** The player this socket belongs to, from the token validated at connect. */
playerId?: number
}
@@ -356,6 +357,38 @@ export class NotificationsHub extends DurableObject<Env> {
private handleInvocation(ws: WebSocket, connectionId: string, msg: HubMessage): void {
switch (msg.target) {
case 'SubscribeTo':
case 'UnsubscribeFrom': {
const argument = msg.arguments?.[0] as
{ Channels?: unknown; channels?: unknown } | undefined
const channels = argument?.Channels ?? argument?.channels
if (
!Array.isArray(channels) ||
channels.length > 128 ||
channels.some(
(channel) => typeof channel !== 'string' || !channel.length || channel.length > 256
)
) {
if (msg.invocationId)
ws.send(this.completionError(msg.invocationId, 'Invalid channel subscriptions.'))
break
}
const state = ws.deserializeAttachment() as SocketState
const current = new Set(state.channels ?? [])
for (const channel of channels) {
if (msg.target === 'SubscribeTo') current.add(channel)
else current.delete(channel)
}
if (current.size > 128) {
if (msg.invocationId)
ws.send(this.completionError(msg.invocationId, 'Too many channel subscriptions.'))
break
}
state.channels = [...current]
ws.serializeAttachment(state)
if (msg.invocationId) ws.send(this.completion(msg.invocationId, null))
break
}
case 'SubscribeToPlayers': {
const playerIds = parsePlayerIds(msg.arguments)
// An argument we can't read is not "subscribe to nobody": subscribing
@@ -65,6 +65,7 @@ const DEFAULT_CONNECT_PLAYER = 9000
interface HubRecord {
type?: number
error?: string
target?: string
invocationId?: string
result?: unknown
@@ -197,6 +198,44 @@ describe('internal endpoint auth', () => {
})
describe('hub protocol', () => {
test('Studio named subscriptions merge and unsubscribe', async () => {
const { ws, waitFor } = await connect('conn-studio-channels')
const stub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
for (const [i, channel] of ['RRS:first', 'RRS:second', 'RRS:first'].entries()) {
send(ws, {
type: 1,
invocationId: `s${i}`,
target: 'SubscribeTo',
arguments: [{ Channels: [channel] }],
})
expect(
(await waitFor((r) => r.type === 3 && r.invocationId === `s${i}`)).error
).toBeUndefined()
}
const channels = () =>
runInDurableObject(
stub,
(_instance, state) =>
state
.getWebSockets()
.map(
(socket) =>
socket.deserializeAttachment() as { connectionId: string; channels?: string[] }
)
.find((attachment) => attachment.connectionId === 'conn-studio-channels')?.channels
)
expect(await channels()).toEqual(['RRS:first', 'RRS:second'])
send(ws, {
type: 1,
invocationId: 'u',
target: 'UnsubscribeFrom',
arguments: [{ Channels: ['RRS:first'] }],
})
expect((await waitFor((r) => r.type === 3 && r.invocationId === 'u')).error).toBeUndefined()
expect(await channels()).toEqual(['RRS:second'])
ws.close()
})
test('GetSubscriptions reflects SubscribeToPlayers', async () => {
const { ws, waitFor } = await connect('conn-subs')
+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' })
})
})
+16
View File
@@ -21,3 +21,19 @@ pnpm test
```sh
pnpm turbo deploy
```
## Studio uploads
`POST /upload/unityassets` accepts authenticated repeated multipart file parts,
including the gzip-compressed multipart requests sent by Studio. It returns one
`{ Filename, Hash }` per part in request order. `Hash` is base64 SHA-256;
`Filename` matches the SDK's content-addressed source manifest: unsigned
little-endian SHA-256 encoded as base36, padded to 50 characters and truncated to
its first 40. Retrying identical content therefore returns the same names.
Source bytes are stored under `unityasset/<Filename>` for CDN GET/HEAD readback.
`POST /upload` also decodes gzip multipart bodies for Studio manifests and room
saves. Malformed compression/forms return 400; unsupported encodings return 415.
Expanded request bodies are bounded before form parsing (with 64 KiB for the
multipart envelope), then the configured file-size limit is checked. Existing
staff size exemptions on the single-file route are preserved.
+171 -4
View File
@@ -51,6 +51,9 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
4: 'video',
5: 'invention',
6: 'room',
8: 'room',
9: 'roomunityasset',
10: 'unityasset',
}
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
@@ -100,6 +103,121 @@ function textField(body: Record<string, unknown>, ...names: string[]): string |
return undefined
}
/** Workers requires quoted disposition parameters; Mono emits valid bare tokens. */
function normalizeMultipartHeaders(contentType: string): TransformStream<Uint8Array, Uint8Array> {
const boundary = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)
const marker = new TextEncoder().encode(`\r\n--${boundary?.[1] ?? boundary?.[2]}`)
let pending = new Uint8Array(0)
let headers = true
return new TransformStream({
transform(chunk, controller) {
const bytes = new Uint8Array(pending.length + chunk.length)
bytes.set(pending)
bytes.set(chunk, pending.length)
pending = bytes
while (pending.length) {
if (headers) {
let end = -1
for (let i = 0; i + 3 < pending.length; i++)
if (
pending[i] === 13 &&
pending[i + 1] === 10 &&
pending[i + 2] === 13 &&
pending[i + 3] === 10
) {
end = i + 4
break
}
if (end < 0) {
if (pending.length > 64 * 1024) throw new Error('Multipart headers exceed limit')
return
}
const text = new TextDecoder()
.decode(pending.subarray(0, end))
.replace(/^Content-Disposition:[^\r\n]*/gim, (line) =>
line.replace(
/(;\s*(?:name|filename)=)([!#$%&'*+\-.^_`|~0-9a-z]+)(?=;|\r|$)/gi,
'$1"$2"'
)
)
controller.enqueue(new TextEncoder().encode(text))
pending = pending.subarray(end)
headers = false
} else {
let start = -1
for (let i = 0; i + marker.length + 1 < pending.length; i++) {
if (
pending[i] === 13 &&
marker.every((byte, j) => pending[i + j] === byte) &&
pending[i + marker.length] === 13 &&
pending[i + marker.length + 1] === 10
) {
start = i
break
}
}
if (start < 0) {
const end = Math.max(0, pending.length - marker.length - 2)
if (end) controller.enqueue(pending.subarray(0, end))
pending = pending.subarray(end)
return
}
const end = start + marker.length + 2
controller.enqueue(pending.subarray(0, end))
pending = pending.subarray(end)
headers = true
}
}
},
flush(controller) {
if (pending.length) controller.enqueue(pending)
},
})
}
/** Studio compresses the entire multipart body, not the individual file parts. */
async function readUploadForm(
request: Request,
limit: number
): Promise<{ form: FormData } | { error: string; status: 400 | 413 | 415 }> {
const encoding = request.headers.get('content-encoding')?.trim().toLowerCase()
if (encoding && encoding !== 'identity' && encoding !== 'gzip')
return { error: 'Unsupported upload encoding.', status: 415 }
let tooLarge = false
try {
let stream = request.body
if (!stream) return { form: new FormData() }
if (encoding === 'gzip') stream = stream.pipeThrough(new DecompressionStream('gzip'))
let decodedBytes = 0
stream = stream.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
decodedBytes += chunk.byteLength
// Account for the multipart envelope while bounding expanded input before parsing.
if (decodedBytes > limit + 64 * 1024) {
tooLarge = true
throw new Error('Decoded upload exceeds limit')
}
controller.enqueue(chunk)
},
})
)
const headers = new Headers(request.headers)
headers.delete('content-encoding')
headers.delete('content-length')
if (/^multipart\/form-data\s*;/i.test(headers.get('content-type') ?? ''))
stream = stream.pipeThrough(normalizeMultipartHeaders(headers.get('content-type')!))
return {
form: await new Request(request.url, { method: 'POST', headers, body: stream }).formData(),
}
} catch {
return {
error: tooLarge ? 'Files exceed the upload limit.' : 'Invalid upload form.',
status: tooLarge ? 413 : 400,
}
}
}
const app = new Hono<App>()
.use(
'*',
@@ -140,6 +258,46 @@ const app = new Hono<App>()
// (the `<upload-date>/<random-name>` part the `cdn` worker serves back) the client
// references it by. Also accepts a name-only post (no binary) that just echoes
// back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`.
.post(
'/upload/unityassets',
describeRoute({
tags: ['Upload'],
summary: 'Upload Studio source files',
security: AUTHED,
description:
'Repeated multipart file parts, returned in request order with filename and SHA-256.',
}),
async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401)
const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES)
const parsed = await readUploadForm(c.req.raw, limit)
if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status)
const form = parsed.form
const files = Array.from(form.values()).filter(
(value): value is File => value instanceof File
)
if (files.length === 0) return c.json({ error: 'No source files supplied.' }, 400)
if (files.reduce((size, file) => size + file.size, 0) > limit)
return c.json({ error: 'Source files exceed the upload limit.' }, 413)
const uploaded = []
for (const file of files) {
const bytes = await file.arrayBuffer()
const hash = await crypto.subtle.digest('SHA-256', bytes)
// SDK XYGEAHCXHVR: unsigned little-endian SHA-256, base36 padded to
// 50 digits, then the first 40 characters. Its source manifest already
// contains this name before upload; random names cannot round-trip.
const name = new Uint8Array(hash)
.reduceRight((n, byte) => n * 256n + BigInt(byte), 0n)
.toString(36)
.padStart(50, '0')
.slice(0, 40)
await c.env.CDN_ASSETS.put(`unityasset/${name}`, bytes, { sha256: hash })
uploaded.push({ Filename: name, Hash: btoa(String.fromCharCode(...new Uint8Array(hash))) })
}
return c.json(uploaded)
}
)
.post(
'/upload',
describeRoute({
@@ -173,16 +331,18 @@ const app = new Hono<App>()
const id = await validateAndGetAccountId(c.req.raw, secret)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const roles = await validateAndGetRoles(c.req.raw, secret)
const isStaff = roles?.some((role) => STAFF_ROLES.has(role)) ?? false
const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES)
const parsed = await readUploadForm(c.req.raw, isStaff ? Number.MAX_SAFE_INTEGER : limit)
if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status)
const body = Object.fromEntries(parsed.form.entries())
// The binary part is identified by being a file (filename/content-type),
// not by its field name — matching the reference's part detection.
const file = Object.values(body).find((v): v is File => v instanceof File)
if (file) {
const roles = await validateAndGetRoles(c.req.raw, secret)
const isStaff = roles?.some((role) => STAFF_ROLES.has(role)) ?? false
const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES)
if (!isStaff && file.size > limit) {
return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413)
}
@@ -208,6 +368,13 @@ const app = new Hono<App>()
// worker answer one from a HEAD instead of downloading the blob to digest it.
sha256: await crypto.subtle.digest('SHA-256', bytes),
})
if (c.req.header('user-agent')?.startsWith('RRStudio')) {
const hash = await crypto.subtle.digest('SHA-256', bytes)
return c.json({
Filename: filename,
Hash: btoa(String.fromCharCode(...new Uint8Array(hash))),
})
}
return c.json({ filename })
}
+165 -1
View File
@@ -235,7 +235,7 @@ it('GET /openapi.json documents every route', async () => {
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual(['GET /', 'POST /upload'])
expect([...documented].sort()).toEqual(['GET /', 'POST /upload', 'POST /upload/unityassets'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
@@ -246,3 +246,167 @@ it('GET /openapi.json documents every route', async () => {
// Every schema inlines: a `$ref` here would be a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
it('uploads Studio source files in order with verified hashes and a manifest under its own prefix', async () => {
const headers = { ...(await bearer()), 'User-Agent': 'RRStudio' }
const body = new FormData()
body.append('file', new File(['first'], 'a.txt'))
body.append('file', new File(['second'], 'b.txt'))
const res = await SELF.fetch(`${ORIGIN}/upload/unityassets`, { method: 'POST', headers, body })
expect(res.status).toBe(200)
const files = (await res.json()) as Array<{ Filename: string; Hash: string }>
expect(files).toHaveLength(2)
for (const [i, file] of files.entries()) {
expect(file.Filename).toMatch(/^[0-9a-z]{40}$/)
const stored = await env.CDN_ASSETS.get(`unityasset/${file.Filename}`)
expect(await stored!.text()).toBe(i === 0 ? 'first' : 'second')
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(i === 0 ? 'first' : 'second')
)
expect(file.Hash).toBe(btoa(String.fromCharCode(...new Uint8Array(hash))))
}
const manifest = new FormData()
manifest.append('fileType', '9')
manifest.append(
'file',
new File(['{"Version":3,"Files":[]}'], 'manifest.json', { type: 'application/json' })
)
const uploaded = await SELF.fetch(`${ORIGIN}/upload`, { method: 'POST', headers, body: manifest })
expect(uploaded.status).toBe(200)
const source = (await uploaded.json()) as { Filename: string; Hash: string }
expect(source.Hash).toBeTruthy()
expect(await (await env.CDN_ASSETS.get(`roomunityasset/${source.Filename}`))!.text()).toBe(
'{"Version":3,"Files":[]}'
)
const unauthenticated = new FormData()
unauthenticated.append('file', new File(['x'], 'x.txt'))
expect(
(await SELF.fetch(`${ORIGIN}/upload/unityassets`, { method: 'POST', body: unauthenticated }))
.status
).toBe(401)
})
it('accepts the Mono multipart wire format without changing binary file bytes', async () => {
const boundary = 'mono-studio-boundary'
const payload = new Uint8Array([0, 255, 128, 13, 10, 0, 200])
const parts: Array<string | Uint8Array> = []
for (let i = 0; i < 2; i++)
parts.push(
`--${boundary}\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name=file; filename=file.bin; filename*=utf-8''file.bin\r\n\r\n`,
payload,
'\r\n'
)
parts.push(`--${boundary}--\r\n`)
const body = await new Response(
new Blob(parts).stream().pipeThrough(new CompressionStream('gzip'))
).arrayBuffer()
const result = await SELF.fetch(`${ORIGIN}/upload/unityassets`, {
method: 'POST',
headers: {
...(await bearer()),
'Content-Type': `multipart/form-data; boundary="${boundary}"`,
'Content-Encoding': 'gzip',
},
body,
})
expect(result.status).toBe(200)
const files = await result.json<Array<{ Filename: string }>>()
expect(files).toHaveLength(2)
expect(files[0].Filename).toBe(files[1].Filename)
const saved = await env.CDN_ASSETS.get(`unityasset/${files[0].Filename}`)
expect(new Uint8Array(await saved!.arrayBuffer())).toEqual(payload)
})
it('accepts Studio gzip multipart batches with stable content-addressed filenames', async () => {
const body = new FormData()
body.append('file', new File(['first'], 'file.bin'))
body.append('file', new File(['second'], 'file.bin'))
body.append('file', new File(['first'], 'file.bin'))
const request = new Request(`${ORIGIN}/upload/unityassets`, { method: 'POST', body })
const compressed = request.body!.pipeThrough(new CompressionStream('gzip'))
const bytes = await new Response(compressed).arrayBuffer()
const headers = {
...(await bearer()),
'User-Agent': 'RRStudio',
'Content-Type': request.headers.get('content-type')!,
'Content-Encoding': 'gzip',
}
const upload = async () => SELF.fetch(request.url, { method: 'POST', headers, body: bytes })
const result = await upload()
expect(result.status).toBe(200)
const files = await result.json<Array<{ Filename: string; Hash: string }>>()
expect(files).toHaveLength(3)
expect(files.map((file) => file.Filename)).toEqual([
'3jz19z865iu7adldcdxv6stlddxq0yp5qjvnon77',
'4wk7uj0ueguhyik1bkmvgcl60upxvkg9wwjbdlrk',
'3jz19z865iu7adldcdxv6stlddxq0yp5qjvnon77',
])
expect(files[0]).toEqual(files[2])
expect(files[0]!.Filename).not.toBe(files[1]!.Filename)
for (const [i, file] of files.entries()) {
expect(await (await env.CDN_ASSETS.get(`unityasset/${file.Filename}`))!.text()).toBe(
i === 1 ? 'second' : 'first'
)
}
expect(await (await upload()).json()).toEqual(files)
// The next SDK request compresses the source manifest through /upload too.
const manifest = JSON.stringify({
Version: 3,
Files: files.map((file, i) => ({ n: file.Filename, p: `Assets/${i}.txt` })),
})
const manifestForm = new FormData()
manifestForm.append('fileType', '9')
manifestForm.append('file', new File([manifest], 'file.bin', { type: 'application/json' }))
const manifestRequest = new Request(`${ORIGIN}/upload`, { method: 'POST', body: manifestForm })
const manifestBytes = await new Response(
manifestRequest.body!.pipeThrough(new CompressionStream('gzip'))
).arrayBuffer()
const saved = await SELF.fetch(manifestRequest.url, {
method: 'POST',
headers: { ...headers, 'Content-Type': manifestRequest.headers.get('content-type')! },
body: manifestBytes,
})
expect(saved.status).toBe(200)
const metadata = await saved.json<{ Filename: string }>()
expect(await (await env.CDN_ASSETS.get(`roomunityasset/${metadata.Filename}`))!.text()).toBe(
manifest
)
})
it('rejects malformed and oversized compressed source uploads without an internal error', async () => {
const headers = {
...(await bearer()),
'Content-Encoding': 'gzip',
'Content-Type': 'multipart/form-data; boundary=test',
}
expect(
(
await SELF.fetch(`${ORIGIN}/upload/unityassets`, {
method: 'POST',
headers,
body: 'invalid gzip',
})
).status
).toBe(400)
const original = env.MAX_UPLOAD_BYTES
env.MAX_UPLOAD_BYTES = '16'
try {
const form = new FormData()
form.append('file', new File([new Uint8Array(100_000)], 'large.bin'))
const request = new Request(`${ORIGIN}/upload/unityassets`, { method: 'POST', body: form })
const compressed = await new Response(
request.body!.pipeThrough(new CompressionStream('gzip'))
).arrayBuffer()
expect(compressed.byteLength).toBeLessThan(1000)
const response = await SELF.fetch(request.url, {
method: 'POST',
headers: { ...headers, 'Content-Type': request.headers.get('content-type')! },
body: compressed,
})
expect(response.status).toBe(413)
} finally {
if (original === undefined) delete env.MAX_UPLOAD_BYTES
else env.MAX_UPLOAD_BYTES = original
}
})
+44 -11
View File
@@ -3,18 +3,18 @@
The RecFlare Studio host (`https://studio.<domain>`).
Device login stays on Auth, `GET /account/me` stays on Accounts, and the approval
page is WWW `/device`. This worker answers the editor's build list, stores a
locally built Windows and Android bundle pair, and answers the co-owner
presence list. It does not serve the bundle bytes. A stored build is loaded from
Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`)
and from the bucket at `/room/{filename}`.
page is WWW `/device`. This worker queues cloud builds, accepts completed bundles,
and serves build status and co-owner presence. Rooms exposes playable bundles in
`CurrentSave.UnitySubAssets`; their bytes are served by the CDN at `/room/{filename}`.
The Rooms `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset` route separately
returns the editable source manifest and available build metadata.
| Method | Path | Purpose |
| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. |
| `GET` | `/cloud-builds/get-build-count-for-user` | Authenticated user's Studio build totals for the current UTC-hour rate-limit window. |
| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished cloud build on the subroom's latest save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. |
| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. |
| Method | Path | Purpose |
| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. |
| `GET` | `/cloud-builds/get-build-count-for-user` | Authenticated user's Studio build totals for the current UTC-hour rate-limit window. |
| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished build on the subroom's current published save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. |
| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. |
## Development
@@ -49,3 +49,36 @@ the rooms migrations too. Deploying does not apply either.
just migrate -F studio # remote
just migrate -F studio -- --local # dev db
```
## Editor build limits
`GET /cloud-builds/get-rate-limit-config` returns the editor's
`CloudBuildRateLimitConfigDTO` directly: `buildThrottleIntervalHours` (1),
`warnAfterBuildsPerInterval` (90), and `maxBuildsPerInterval` (100). These names
matter: unknown keys leave the DTO's maximum at zero and block even a first build.
The authenticated `get-build-count-for-user` route provides the user's counts.
## Queued Studio builds
`POST /cloud-builds/create` accepts the SDK's room, subroom, save, auto-publish,
and optional target fields. It requires Studio authorization and room management
rights, validates the uploaded source manifest, and returns a numeric string
build ID with a null completion time. Repeated requests for an in-progress save
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
`PUT /cloud-builds/cancel` cancels a queued or running job.
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with
the matching credential. Without a key, creation returns an explicit unavailable
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to
the enabled targets.
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
routes lease jobs to that worker. Only validated UnityFS artifacts for the
allocated asset IDs and requested platforms are accepted. SDK bundle version 5
is retained. Completion links the submitted save; auto-publish does not replace
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
Claims include a signed, short-lived SDK validation token with no account subject
or roles. Deploy the paired API support: only bulk component-text validation
accepts this credential. It cannot authenticate as an account or publish a room.
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
cloud_build_id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
lease_token TEXT,
lease_until TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_studio_build_job_queue ON studio_cloud_build_job(status, created_at);
+528
View File
@@ -0,0 +1,528 @@
import { Hono } from 'hono'
import {
canManageRoomById,
getRoomById,
getSubRoom,
getSubRoomSaveById,
publicStudioBundleFilename,
studioBundleR2Key,
} from '@repo/domain'
import {
generateStudioBuildValidationToken,
validateAndGetAccountId,
validateAndGetRoles,
} from '@repo/jwt'
import { parseMultipart } from './multipart'
import type { App } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)`
interface Job {
buildId: string
roomId: number
subRoomId: number
saveId: number
accountId: number
unityAssetId: string
autoPublish: boolean
templateVersion: number
targets: number[]
manifest: {
Version: number
MainSceneProjectRelativePath: string
ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: string[] }>
}
room: unknown
exports: Array<{ e: string; u: string }>
}
interface Lease {
payload: string
lease_token: string
lease_until: string
}
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID()
interface Artifact {
field: string
unityAssetId: string
target: number
kind: string
version: number
}
function isArtifact(value: unknown): value is Artifact {
if (typeof value !== 'object' || value === null) return false
const entry = value as Record<string, unknown>
return (
typeof entry.field === 'string' &&
typeof entry.unityAssetId === 'string' &&
typeof entry.target === 'number' &&
typeof entry.kind === 'string' &&
typeof entry.version === 'number'
)
}
function numericId() {
return (
(BigInt(crypto.getRandomValues(new Uint32Array(1))[0] & 0x7fffffff) << 32n) |
BigInt(crypto.getRandomValues(new Uint32Array(1))[0]) |
1n
).toString()
}
interface CloudBuildRecord {
cloud_build_id: string
started_at: string
completed_at: string | null
error: string | null
unity_asset_id: string
created_by_account_id: number
}
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) {
return {
cloudBuildId: row.cloud_build_id,
startedAt: row.started_at,
completedAt: row.completed_at || null,
error: row.error,
unityAssetId: row.unity_asset_id,
createdByAccountId: row.created_by_account_id,
serverDataFileName:
row.completed_at && !row.error
? `studio-builds/${row.cloud_build_id}/server-data.json`
: null,
}
}
async function privateAuth(request: Request, key: string | undefined) {
if (!key) return false
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(request.headers.get('authorization') ?? '')
)
return new Uint8Array(wanted).every((byte, i) => byte === new Uint8Array(got)[i])
}
async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, saveId: number) {
return db
.prepare(
"SELECT b.* FROM studio_cloud_build b JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE b.room_id=? AND b.sub_room_id=? AND b.sub_room_data_save_id=? AND j.status IN ('queued','building','publishing') ORDER BY b.started_at DESC LIMIT 1"
)
.bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>()
}
const app = new Hono<App>()
.post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret)
const roles = await validateAndGetRoles(c.req.raw, secret)
if (accountId === null) return c.body(null, 401)
if (!roles?.includes('betastudio'))
return c.json(fail('Studio upload authorization is required.'), 403)
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
const form = await c.req.parseBody()
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
return c.json(fail('Invalid room, subroom, or save ID.'), 400)
const [roomId, subRoomId, saveId] = ids.map(Number)
if (!(await canManageRoomById(c.env.DB, roomId, accountId)))
return c.json(fail('This account cannot build this room.'), 403)
if (!(await getSubRoom(c.env.DB, roomId, subRoomId)))
return c.json(fail('No such subroom.'), 404)
const pending = await pendingBuild(c.env.DB, roomId, subRoomId, saveId)
if (pending) return c.json(ok(wire(pending)))
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
const source = save?.StudioSourceAsset as { Filename?: string } | undefined
if (!source?.Filename) return c.json(fail('This save has no Studio source project.'), 400)
const object = await c.env.CDN_ASSETS.get(`roomunityasset/${source.Filename}`)
if (!object) return c.json(fail('The uploaded Studio source manifest is missing.'), 400)
if (object.size > 8 * 1024 * 1024) return c.json(fail('Source manifest exceeds limit.'), 413)
let manifest: Job['manifest']
try {
manifest = await object.json<Job['manifest']>()
} catch {
return c.json(fail('Invalid Studio source manifest JSON.'), 400)
}
if (
!manifest ||
manifest.Version !== 3 ||
manifest.ExportPartitionVersion !== 3 ||
typeof manifest.MainSceneProjectRelativePath !== 'string' ||
!manifest.MainSceneProjectRelativePath.startsWith('Assets/') ||
!manifest.MainSceneProjectRelativePath.endsWith('.unity') ||
!Array.isArray(manifest.Files) ||
manifest.Files.length > 100000 ||
manifest.Files.some(
(file) =>
!file ||
typeof file.p !== 'string' ||
!(file.p.startsWith('Assets/') || file.p.startsWith('Packages/')) ||
file.p
.split('/')
.some(
(part) =>
part === '..' ||
part === '.' ||
part === '' ||
part.toLowerCase().endsWith('.bundle')
) ||
file.p.includes('\\') ||
file.p.includes(':') ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(file.p) ||
!/^[a-z0-9]{40}$/.test(file.n)
) ||
!manifest.Files.some((file) => file.p === manifest.MainSceneProjectRelativePath) ||
new Set(manifest.Files.map((file) => file.p.toLowerCase())).size !== manifest.Files.length ||
(manifest.Exports !== undefined &&
(!Array.isArray(manifest.Exports) ||
manifest.Exports.some(
(item) =>
!item ||
typeof item.e !== 'string' ||
!item.e ||
(item.d !== undefined &&
(!Array.isArray(item.d) || item.d.some((id) => typeof id !== 'string')))
) ||
new Set(manifest.Exports.map((item) => item.e)).size !== manifest.Exports.length))
)
return c.json(fail('Invalid Studio source file manifest.'), 400)
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
if (!targets.length || targets.some((target) => target !== 0 && target !== 2))
return c.json(fail('Invalid build target configuration.'), 503)
const requested = textField(form.buildAssetBundleTypes).split(',').filter(Boolean).map(Number)
if (requested.some((target) => !targets.includes(target)))
return c.json(fail('Requested build platform is unavailable.'), 400)
const now = new Date().toISOString()
const job: Job = {
buildId: numericId(),
roomId,
subRoomId,
saveId,
accountId,
unityAssetId: guid(),
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
targets: requested.length ? requested : targets,
manifest,
room: await getRoomById(c.env.DB, roomId),
exports: (manifest.Exports ?? []).map((item) => ({
e: item.e,
u: guid(),
})),
}
// The initial lookup is only a fast path. Check again inside the creation
// transaction so concurrent retries cannot dispatch two builds for one save.
const created = await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO studio_cloud_build (cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
SELECT ?,?,?,?,?,?,?,'',NULL WHERE NOT EXISTS (
SELECT 1 FROM studio_cloud_build b JOIN studio_cloud_build_job j USING(cloud_build_id)
WHERE b.room_id=? AND b.sub_room_id=? AND b.sub_room_data_save_id=? AND j.status IN ('queued','building','publishing'))`
).bind(
job.buildId,
roomId,
subRoomId,
saveId,
job.unityAssetId,
accountId,
now,
roomId,
subRoomId,
saveId
),
c.env.DB.prepare(
'INSERT INTO studio_cloud_build_job (cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
])
if (!created[0].meta.changes) {
const existing = await pendingBuild(c.env.DB, roomId, subRoomId, saveId)
return existing
? c.json(ok(wire(existing)))
: c.json(fail('Build state changed; retry creation.'), 409)
}
return c.json(
ok(
wire({
cloud_build_id: job.buildId,
started_at: now,
completed_at: null,
error: null,
unity_asset_id: job.unityAssetId,
created_by_account_id: accountId,
})
)
)
})
.get('/cloud-builds', async (c) => {
const id = c.req.query('cloudBuildId') ?? ''
if (!ID.test(id)) return c.json(fail('Invalid build ID.'), 400)
const row = await c.env.DB.prepare('SELECT * FROM studio_cloud_build WHERE cloud_build_id = ?')
.bind(id)
.first<CloudBuildRecord>()
return row ? c.json(ok(wire(row))) : c.json(fail('No such build.'), 404)
})
.on(['POST', 'PUT'], '/cloud-builds/cancel', async (c) => {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const form = await c.req.parseBody()
const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare(
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
)
.bind(id)
.first<{ room_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='cancelled',lease_token=NULL WHERE cloud_build_id=? AND status IN ('queued','building')"
).bind(id),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error='Build cancelled.' WHERE cloud_build_id=? AND completed_at='' AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=studio_cloud_build.cloud_build_id AND status='cancelled')"
).bind(new Date().toISOString(), id),
])
return c.json(ok(null))
})
.use('/internal/cloud-builds/*', async (c, next) => {
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
return next()
})
.post('/internal/cloud-builds/claim', async (c) => {
const now = new Date().toISOString()
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build SET error='Build worker lease expired.',completed_at=? WHERE completed_at='' AND cloud_build_id IN (SELECT cloud_build_id FROM studio_cloud_build_job WHERE lease_until<? AND ((status='building' AND attempts>=3) OR status='publishing'))"
).bind(now, now),
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE lease_until<? AND ((status='building' AND attempts>=3) OR status='publishing')"
).bind(now),
])
const token = guid()
const until = new Date(Date.now() + 5 * 60 * 1000).toISOString()
const lease = await c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='building',lease_token=?,lease_until=?,attempts=attempts+1
WHERE cloud_build_id=(SELECT cloud_build_id FROM studio_cloud_build_job WHERE (status='queued' OR (status='building' AND lease_until<?)) AND attempts<3 ORDER BY created_at LIMIT 1)
RETURNING payload,lease_token,lease_until`
)
.bind(token, until, now)
.first<Lease>()
if (!lease) return c.json(null)
const job = JSON.parse(lease.payload) as Job
return c.json({
job,
leaseToken: token,
leaseUntil: until,
validationAccessToken: await generateStudioBuildValidationToken(
job.buildId,
await c.env.JWT_SECRET.get()
),
})
})
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
const result = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
)
.bind(
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.run()
return c.json({ active: result.meta.changes > 0 })
})
.post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw)
const id = c.req.param('id')
const lease = await c.env.DB.prepare(
"SELECT payload,lease_token,lease_until FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='building' AND lease_until>?"
)
.bind(id, textField(form.leaseToken), new Date().toISOString())
.first<Lease>()
if (!lease) return c.json(fail('Build lease expired or cancelled.'), 409)
const job = JSON.parse(lease.payload) as Job
const error = typeof form.error === 'string' ? form.error.slice(0, 16000) : null
if (error) {
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed' WHERE cloud_build_id=? AND lease_token=? AND status='building'"
).bind(id, lease.lease_token),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET error=?,completed_at=? WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='failed')"
).bind(error, new Date().toISOString(), id, id, lease.lease_token),
])
return c.json(ok(null))
}
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown
try {
entries = JSON.parse(textField(form.artifacts, '[]'))
} catch {
return c.json(fail('Invalid build artifact metadata.'), 400)
}
if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400)
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>()
for (const entry of entries) {
const file = form[entry.field]
const key = `${entry.unityAssetId}:${entry.target}:${entry.kind}`
if (
!assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) ||
entry.version !== 5 ||
!(file instanceof File) ||
file.size === 0 ||
seen.has(key)
)
return c.json(fail('Invalid build artifact.'), 400)
seen.add(key)
}
for (const asset of assets)
for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400)
const writes: D1PreparedStatement[] = []
for (const entry of entries) {
const file = form[entry.field] as File
if (new TextDecoder().decode(await file.slice(0, 8).arrayBuffer()) !== 'UnityFS\0')
return c.json(fail('Artifact is not a UnityFS asset bundle.'), 400)
}
const locked = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='publishing' WHERE cloud_build_id=? AND lease_token=? AND status='building' AND lease_until>?"
)
.bind(id, lease.lease_token, new Date().toISOString())
.run()
if (!locked.meta.changes) return c.json(fail('Build lease expired or cancelled.'), 409)
const keys: string[] = []
try {
for (const entry of entries) {
const file = form[entry.field] as File
const bytes = await file.arrayBuffer()
const hash = btoa(
String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)))
)
const baseFilename = publicStudioBundleFilename(
job.roomId,
entry.unityAssetId,
entry.target === 2 ? 'android' : 'windows',
entry.kind
)
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = studioBundleR2Key(filename)
await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key)
writes.push(
c.env.DB.prepare(
"INSERT INTO unity_asset (unity_asset_id,target,version,kind,filename,hash) SELECT ?,?,?,?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing') ON CONFLICT(unity_asset_id,target,version,kind) DO UPDATE SET filename=excluded.filename,hash=excluded.hash"
).bind(
entry.unityAssetId,
entry.target,
entry.version,
entry.kind,
filename,
hash,
id,
lease.lease_token
)
)
}
const serverDataKey = `roomunityasset/studio-builds/${id}/server-data.json`
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
keys.push(serverDataKey)
// The exact submitted save is linked; a newer draft must never be replaced.
writes.push(
c.env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
}
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(new Date().toISOString(), id, id, lease.lease_token)
)
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
).bind(id, lease.lease_token)
)
// Recheck the lease and target inside the publication transaction. R2
// uploads can outlast a lease, and a room can be deleted while compiling.
const publicationTime = new Date().toISOString()
const results = await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
AND (lease_until<=? OR NOT EXISTS (
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
c.env.DB.prepare(
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
).bind(publicationTime, id, id),
...writes,
])
if (!results[results.length - 1].meta.changes) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
return c.json(fail('Build lease expired or target save removed.'), 409)
}
} catch (error) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='building' WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
)
.bind(id, lease.lease_token)
.run()
throw error
}
return c.json(ok(null))
})
export default app
+4
View File
@@ -17,6 +17,10 @@ export type Env = SharedHonoEnv & {
// Shared `recflare-cdn` bucket. Local Studio builds land under
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
CLOUD_BUILD_KEY?: string
CLOUD_BUILD_TARGETS?: string
CLOUD_BUILD_TEMPLATE_VERSION?: string
}
/** Variables can be extended */
+6 -1
View File
@@ -19,6 +19,7 @@ export interface CloudBuildRow {
error: string | null
unityAssetId: string
createdByAccountId: number
serverDataFileName?: string | null
}
export type BundlePlatform = StudioBundlePlatform
@@ -58,10 +59,14 @@ function toCloudBuild(row: BuildListRow): CloudBuildRow {
return {
cloudBuildId: row.cloud_build_id,
startedAt: row.started_at,
completedAt: row.completed_at,
completedAt: row.completed_at || null,
error: row.error,
unityAssetId: row.unity_asset_id,
createdByAccountId: row.created_by_account_id,
serverDataFileName:
row.completed_at && !row.error && /^\d+$/.test(row.cloud_build_id)
? `studio-builds/${row.cloud_build_id}/server-data.json`
: null,
}
}
+10 -8
View File
@@ -10,6 +10,7 @@ import {
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import cloudBuilds from './cloud-builds'
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
import { parseMultipart } from './multipart'
@@ -160,10 +161,14 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.route('/', cloudBuilds)
.get('/', async (c) => {
return c.text('hello, world!')
})
// There is no circuit recommendation corpus on a fresh deployment. An empty
// list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
// The editor lists a room's cloud builds with
// GET /cloud-builds/for-room?roomId=&subRoomId=&skip=&take= on this host.
@@ -182,16 +187,13 @@ const app = new Hono<App>()
// Local Windows + Android bundles from the editor button under Upload.
.post('/cloud-builds/from-editor', (c) => postLocalCloudBuild(c))
// Studio asks how often it may request cloud builds. Stubbed: this server
// never queues a real cloud build (every build is local, see from-editor),
// so the numbers are permissive placeholders. The field names are an
// ASSUMPTION — what the editor reads from this response has not been
// observed; adjust once it has.
// CloudBuildRateLimitConfigDTO reads these three fields directly (no Result
// envelope). Missing MaxBuildsPerInterval becomes zero and blocks the first build.
.get('/cloud-builds/get-rate-limit-config', (c) =>
c.json({
maxBuildsPerHour: 100,
maxConcurrentBuilds: 10,
cooldownSeconds: 0,
buildThrottleIntervalHours: 1,
warnAfterBuildsPerInterval: 90,
maxBuildsPerInterval: 100,
})
)
+10 -4
View File
@@ -19,6 +19,12 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
const TEST_SECRET = 'test-signing-key'
it('returns an empty recommendation list when no circuit corpus is installed', async () => {
const response = await SELF.fetch(`${ORIGIN}/commonQuery/rrstudio/chiprecommendations`)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
})
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
@@ -113,13 +119,13 @@ it('lists no cloud builds for a room', async () => {
expect(await res.json()).toEqual({ results: [], totalResults: 0 })
})
it('serves a stubbed cloud build rate limit config', async () => {
it('serves the Studio rate-limit DTO without blocking a first build', async () => {
const res = await SELF.fetch('https://example.com/cloud-builds/get-rate-limit-config')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
maxBuildsPerHour: 100,
maxConcurrentBuilds: 10,
cooldownSeconds: 0,
buildThrottleIntervalHours: 1,
warnAfterBuildsPerInterval: 90,
maxBuildsPerInterval: 100,
})
})
@@ -0,0 +1,470 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
ROOM_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const origin = 'https://example.com'
const key = 'cloud-worker-private-test-key'
const worker = { Authorization: `Bearer ${key}` }
const b64 = (value: string | ArrayBuffer) =>
btoa(typeof value === 'string' ? value : String.fromCharCode(...new Uint8Array(value)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
async function auth(id = 42, studio = true) {
const payload = b64(
JSON.stringify({
sub: String(id),
role: studio ? ['betastudio'] : [],
exp: Date.now() / 1000 + 600,
})
)
const input = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + '.' + payload
const secret = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode('test-key'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
return {
Authorization:
'Bearer ' +
input +
'.' +
b64(await crypto.subtle.sign('HMAC', secret, new TextEncoder().encode(input))),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = key
env.CLOUD_BUILD_TARGETS = '0'
for (const sql of [
...ROOM_SCHEMA_DDL,
...SUBROOM_SCHEMA_DDL,
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
...UNITY_ASSET_SCHEMA_DDL,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
await env.DB.prepare('INSERT INTO room (data) VALUES (?)')
.bind(
JSON.stringify({
RoomId: 3000,
Name: 'CloudTest',
CreatorAccountId: 42,
Roles: [],
Tags: [],
Accessibility: 1,
})
)
.run()
await env.DB.prepare(
'INSERT INTO subroom (sub_room_id,room_id,data,current_save_id) VALUES (3001,3000,?,3002)'
)
.bind(JSON.stringify({ Name: 'Home' }))
.run()
await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_data_save_id,sub_room_id,data) VALUES (3002,3001,?)'
)
.bind(JSON.stringify({ StudioSourceAsset: { Filename: 'manifest' }, UnitySubAssets: [] }))
.run()
await env.CDN_ASSETS.put(
'roomunityasset/manifest',
JSON.stringify({
Version: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
ExportPartitionVersion: 3,
Files: [{ n: 'a'.repeat(40), p: 'Assets/Test.unity' }],
Exports: [],
})
)
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
await env.CDN_ASSETS.put(
'roomunityasset/manifest',
JSON.stringify({
Version: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
ExportPartitionVersion: 3,
Files: [{ n: 'a'.repeat(40), p: 'Assets/Test.unity' }],
Exports: [],
})
)
})
async function create(id = 42, studio = true, extra = {}) {
return SELF.fetch(origin + '/cloud-builds/create', {
method: 'POST',
headers: await auth(id, studio),
body: new URLSearchParams({
roomId: '3000',
subRoomId: '3001',
subRoomDataSaveId: '3002',
autoPublish: 'false',
...extra,
}),
})
}
async function claim() {
const response = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
method: 'POST',
headers: worker,
})
expect(response.status).toBe(200)
return response.json<{
job: {
buildId: string
unityAssetId: string
targets: number[]
exports: Array<{ e: string; u: string }>
}
leaseToken: string
validationAccessToken: string
}>()
}
it('rejects malformed source manifests and preserves compact SDK export identifiers', async () => {
for (const manifest of [
null,
{ Files: [null] },
{
Version: 3,
ExportPartitionVersion: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
Files: [{ p: 'Assets/../Test.unity', n: 'a'.repeat(40) }],
},
]) {
await env.CDN_ASSETS.put('roomunityasset/manifest', JSON.stringify(manifest))
expect((await create()).status).toBe(400)
}
await env.CDN_ASSETS.put(
'roomunityasset/manifest',
JSON.stringify({
Version: 3,
ExportPartitionVersion: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
Files: [{ p: 'Assets/Test.unity', n: 'a'.repeat(40) }],
Exports: [{ e: 'object-1', d: [] }],
})
)
expect((await create()).status).toBe(200)
const lease = await claim()
expect(lease.job.exports).toEqual([
{ e: 'object-1', u: expect.stringMatching(/^[0-9a-f-]{36}$/) },
])
})
it('requires Studio authorization and room ownership', async () => {
expect((await create(42, false)).status).toBe(403)
expect((await create(99)).status).toBe(403)
expect((await create(42, true, { subRoomDataSaveId: '9999' })).status).toBe(400)
})
it('queues a numeric SDK build ID and returns the exact saved build contract', async () => {
const response = await create()
expect(response.status).toBe(200)
const result = await response.json<{
value: { cloudBuildId: string; completedAt: null; unityAssetId: string }
}>()
expect(result.value.cloudBuildId).toMatch(/^\d+$/)
expect(BigInt(result.value.cloudBuildId)).toBeLessThan(1n << 63n)
expect(result.value.completedAt).toBeNull()
expect(
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
).toBe(result.value.cloudBuildId)
const status = await SELF.fetch(
origin + '/cloud-builds?cloudBuildId=' + result.value.cloudBuildId
)
expect((await status.json<{ value: unknown }>()).value).toEqual(result.value)
const lease = await claim()
expect(lease.job.buildId).toBe(result.value.cloudBuildId)
expect(lease.job.targets).toEqual([0])
const validation = JSON.parse(
atob(lease.validationAccessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
)
expect(validation.aud).toBe('studio-build-validation')
expect(validation.sub).toBeUndefined()
expect(validation.buildId).toBe(lease.job.buildId)
const wrong = await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/heartbeat`, {
method: 'POST',
headers: { ...worker, 'Content-Type': 'application/json' },
body: JSON.stringify({ leaseToken: 'wrong' }),
})
expect(await wrong.json()).toEqual({ active: false })
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('error', 'Real compiler failure')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(200)
const failed = await SELF.fetch(origin + '/cloud-builds?cloudBuildId=' + lease.job.buildId)
expect((await failed.json<{ value: { error: string; completedAt: string } }>()).value.error).toBe(
'Real compiler failure'
)
})
it('reclaims expired jobs with a new lease and rejects the stale worker', async () => {
await create()
const first = await claim()
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until='2000-01-01' WHERE cloud_build_id=?"
)
.bind(first.job.buildId)
.run()
const second = await claim()
expect(second.job.buildId).toBe(first.job.buildId)
expect(second.leaseToken).not.toBe(first.leaseToken)
const form = new FormData()
form.set('leaseToken', first.leaseToken)
form.set('error', 'stale worker')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${first.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(409)
})
it('gates worker dispatch, rejects incomplete artifacts, and stores real-format version 5 bundles', async () => {
expect(
(await SELF.fetch(origin + '/internal/cloud-builds/claim', { method: 'POST' })).status
).toBe(401)
await create(42, true, { autoPublish: 'true' })
const lease = await claim()
await env.DB.prepare(
"INSERT INTO subroom_save (sub_room_data_save_id,sub_room_id,data) VALUES (3003,3001,'{}') ON CONFLICT DO NOTHING"
).run()
await env.DB.prepare('UPDATE subroom SET staged_save_id=3003 WHERE sub_room_id=3001').run()
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('artifacts', '[]')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(400)
form.set(
'artifacts',
JSON.stringify([
{
field: 'bundle0',
unityAssetId: lease.job.unityAssetId,
target: 0,
kind: 'main',
version: 5,
},
])
)
form.set('bundle0', new File(['UnityFS\0test-fixture-bundle'], 'bundle.assetbundle'))
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(200)
const row = await env.DB.prepare(
'SELECT version,hash,filename FROM unity_asset WHERE unity_asset_id=?'
)
.bind(lease.job.unityAssetId)
.first<{ version: number; hash: string; filename: string }>()
expect(row?.version).toBe(5)
expect(row?.hash).toBeTruthy()
expect(await (await env.CDN_ASSETS.get('room/' + row!.filename))?.text()).toBe(
'UnityFS\0test-fixture-bundle'
)
const save = await env.DB.prepare(
"SELECT json_extract(data,'$.UnityAssetId') AS asset FROM subroom_save WHERE sub_room_data_save_id=3002"
).first<{ asset: string }>()
expect(save?.asset).toBe(lease.job.unityAssetId)
expect(
await env.DB.prepare(
'SELECT current_save_id,staged_save_id FROM subroom WHERE sub_room_id=3001'
).first()
).toMatchObject({ current_save_id: 3002, staged_save_id: 3003 })
})
it('cancels queued jobs without allowing a late worker to complete them', async () => {
const response = await create()
const { value } = await response.json<{ value: { cloudBuildId: string } }>()
const lease = await claim()
expect(lease.job.buildId).toBe(value.cloudBuildId)
const cancel = await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'POST',
headers: await auth(),
body: new URLSearchParams({ cloudBuildId: value.cloudBuildId }),
})
expect(cancel.status).toBe(200)
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('error', 'late failure')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(409)
})
async function finish(lease: Awaited<ReturnType<typeof claim>>, artifacts: string) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('artifacts', artifacts)
form.set('bundle', new File(['UnityFS\0test-fixture-bundle'], 'bundle.assetbundle'))
return SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
}
function artifacts(lease: Awaited<ReturnType<typeof claim>>) {
return JSON.stringify([
{ field: 'bundle', unityAssetId: lease.job.unityAssetId, target: 0, kind: 'main', version: 5 },
])
}
it('rejects malformed artifact metadata without consuming the lease', async () => {
await create()
const lease = await claim()
for (const value of ['{', 'null', '{}', '[null]', '[42]', '[{"field":false}]'])
expect((await finish(lease, value)).status).toBe(400)
expect((await finish(lease, artifacts(lease))).status).toBe(200)
})
it('publishes a built staged save and marks completion in one transaction', async () => {
await env.DB.prepare(
'UPDATE subroom SET current_save_id=3003,staged_save_id=3002 WHERE sub_room_id=3001'
).run()
await create(42, true, { autoPublish: 'true' })
const lease = await claim()
expect((await finish(lease, artifacts(lease))).status).toBe(200)
expect(
await env.DB.prepare(
'SELECT current_save_id,staged_save_id FROM subroom WHERE sub_room_id=3001'
).first()
).toEqual({ current_save_id: 3002, staged_save_id: null })
})
it('rolls back completion and uploaded objects when room publication fails, allowing retry', async () => {
await env.DB.prepare(
"UPDATE room SET data=json_remove(data,'$.BecameRRStudioRoomAt') WHERE room_id=3000"
).run()
await env.DB.prepare(
"CREATE TRIGGER fail_studio_publication BEFORE UPDATE ON room BEGIN SELECT RAISE(ABORT, 'publication fixture failure'); END"
).run()
await create(42, true, { autoPublish: 'true' })
const lease = await claim()
try {
expect((await finish(lease, artifacts(lease))).status).toBe(500)
expect(
await env.DB.prepare('SELECT completed_at FROM studio_cloud_build WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ completed_at: '' })
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'building' })
expect(
await env.CDN_ASSETS.get(`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`)
).toBeNull()
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
} finally {
await env.DB.prepare('DROP TRIGGER fail_studio_publication').run()
}
expect((await finish(lease, artifacts(lease))).status).toBe(200)
})
it('fails a repeatedly expired job instead of leaving it queued forever', async () => {
await create()
const lease = await claim()
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET attempts=3,lease_until='2000-01-01' WHERE cloud_build_id=?"
)
.bind(lease.job.buildId)
.run()
expect(await claim()).toBeNull()
expect(
await env.DB.prepare(
'SELECT status,lease_token FROM studio_cloud_build_job WHERE cloud_build_id=?'
)
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed', lease_token: null })
const status = await SELF.fetch(origin + '/cloud-builds?cloudBuildId=' + lease.job.buildId)
expect((await status.json<{ value: { error: string } }>()).value.error).toBe(
'Build worker lease expired.'
)
})
it('reuses one queued build when create requests arrive concurrently', async () => {
const responses = await Promise.all([create(), create(), create()])
const ids = await Promise.all(
responses.map(async (response) => {
expect(response.status).toBe(200)
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
})
)
expect(new Set(ids).size).toBe(1)
expect(
await env.DB.prepare('SELECT COUNT(*) AS count FROM studio_cloud_build_job').first()
).toEqual({ count: 1 })
})
it('does not complete a build whose submitted save was deleted during compilation', async () => {
await create()
const lease = await claim()
const save = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
await env.DB.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002').run()
try {
expect((await finish(lease, artifacts(lease))).status).toBe(409)
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed' })
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.CDN_ASSETS.get(`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`)
).toBeNull()
} finally {
await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_data_save_id,sub_room_id,data) VALUES (3002,3001,?)'
)
.bind(save!.data)
.run()
}
})