mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] Complete editor uploads and queued cloud builds (#135)
* [studio] Complete editor uploads and queued cloud builds * [studio] Protect unpublished source and remove unused notification RPC
This commit is contained in:
@@ -105,8 +105,8 @@ inconsistency here without checking the client first.
|
||||
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
|
||||
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
|
||||
`subRoomDataSave` is camelCase with a different field set from the PascalCase
|
||||
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
|
||||
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
|
||||
`CurrentSave` embedded in the room (no moderation state or asset arrays;
|
||||
version fields are camelCase, alongside `unityAsset`/`unityAssetHash`). Keep the projections distinct.
|
||||
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
|
||||
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
|
||||
nothing. The key must be present (null before the first publish); read it via
|
||||
@@ -123,8 +123,8 @@ inconsistency here without checking the client first.
|
||||
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, and a
|
||||
PASCALCASE row like the list serves — NOT the camelCase projection the room save's
|
||||
response uses (served that, the client showed a save with every value missing). It
|
||||
differs from the list row only in how it names bundles: each `UnitySubAssets` entry is
|
||||
`{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle is lifted to top-level
|
||||
differs from the list row only in how it names bundles: dependency entries in `UnitySubAssets`
|
||||
are `{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle appears only at top-level
|
||||
`UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep
|
||||
them straight.
|
||||
- The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -38,8 +38,12 @@ Reads are the exception: they carry no envelope at all. A paged read (the save h
|
||||
same number, because the client's paged DTO and the reference disagree on the name.
|
||||
`GET /rooms/{roomId}` puts a Studio build's main bundles on `CurrentSave.UnitySubAssets`
|
||||
(target 0 Windows, 2 Android/Quest). `unityAssetTarget` keeps only that target. Maker-pen
|
||||
saves keep the arrays empty. `GET …/unityasset?unityAssetId=` is the bare metadata for
|
||||
those files; the bytes are on the CDN.
|
||||
saves keep the arrays empty. `GET …/unityasset?unityAssetId=<guid>` returns the
|
||||
editable source manifest's filename and hash, plus available baked-build metadata.
|
||||
The current published source is public; other revisions require save-history
|
||||
permissions. Without an asset ID, authorized save readers receive the latest
|
||||
source, while other callers receive only the current published source. A save
|
||||
with only baked bundles has no editable source and returns 404.
|
||||
`POST /unity_assets/baked/bulk` (form `target=0&version=3&id=<guid>&id=<guid>`) is the
|
||||
2025 client's bulk lookup of baked bundles from the `unity_asset` table — a bare array of
|
||||
`{ UnityAssetId, Target, Version, Filename, Hash }`, one per asset that has a build.
|
||||
|
||||
@@ -192,8 +192,11 @@ export const SubRoomDataSaveResponseDto = z.object({
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Base64 SHA-256 of `unityAsset` when that file is set; otherwise null'),
|
||||
dataBlob: z.string(),
|
||||
dataBlob: z.string().nullable(),
|
||||
dataBlobHash: z.string().nullable().describe('Echoed from the request’s `SubRoomData.Hash`'),
|
||||
persistenceVersion: z.int(),
|
||||
omVersion: z.int(),
|
||||
ugcSubVersion: z.int(),
|
||||
savedByAccountId: z.int().nullable(),
|
||||
savedOnPlatform: z
|
||||
.int()
|
||||
@@ -225,11 +228,11 @@ export const UnitySubAssetDto = z.object({
|
||||
export const SubRoomDataSaveDto = z.object({
|
||||
UnitySubAssets: z
|
||||
.array(UnitySubAssetDto)
|
||||
.describe('Main Studio bundles for this save; empty for a maker-pen save'),
|
||||
.describe('Subasset dependencies, excluding the main bundle; empty when none'),
|
||||
ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'),
|
||||
SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'),
|
||||
SubRoomId: z.int().describe('The owning subroom — re-pointed when a subroom is cloned'),
|
||||
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
|
||||
DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'),
|
||||
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'),
|
||||
PersistenceVersion: z.int(),
|
||||
OMVersion: z.int(),
|
||||
@@ -439,7 +442,7 @@ export const RoomBoostCount = z
|
||||
* A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name
|
||||
* lookups answer an empty object rather than a 404 — the client reads that as "no room".
|
||||
*/
|
||||
export const RoomLookup = z.union([RoomDto, z.object({})])
|
||||
export const RoomLookup = z.union([RoomDto, z.object({}), z.null()])
|
||||
|
||||
/** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */
|
||||
export const MissingLookupParam = z
|
||||
@@ -588,6 +591,14 @@ export const RoomEnvelope = z.object({
|
||||
value: RoomDto.nullable(),
|
||||
})
|
||||
|
||||
/** Result<T> consumed by Studio's v2 settings API. */
|
||||
export const StudioRoomEnvelope = z.object({
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
Value: RoomDto.nullable(),
|
||||
})
|
||||
export const RoomRestrictionsEnvelope = z.union([RoomEnvelope, StudioRoomEnvelope])
|
||||
|
||||
/**
|
||||
* What `POST /rooms/{roomId}/subrooms/{subRoomId}/data` answers: `value` carries BOTH the
|
||||
* updated room and the save that was just created. Note `error` is NULL here, not the
|
||||
@@ -796,6 +807,7 @@ export const BulkRoomsRequest = z.object({
|
||||
* the fields actually posted are changed, and the names are matched case-insensitively.
|
||||
*/
|
||||
export const RestrictionsRequest = z.object({
|
||||
ageRating: z.string().optional().describe('0 unset, 1 all ages, 2 ages 13 and up'),
|
||||
supportsScreens: z.string().optional().describe('`True` / `False`'),
|
||||
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
|
||||
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
|
||||
@@ -949,6 +961,8 @@ export const SaveSubRoomDataRequest = z.object({
|
||||
.optional()
|
||||
.describe('The save comment — a description of THIS revision, not the room’s description'),
|
||||
PersistenceVersion: z.int().optional().describe('Recorded on the save and the subroom'),
|
||||
OMVersion: z.int().optional().describe('Object-model format version; retained when omitted'),
|
||||
UgcSubVersion: z.int().optional().describe('UGC format revision; retained when omitted'),
|
||||
InventionUsage: z.string().optional().describe('Recorded on the subroom'),
|
||||
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
|
||||
AutoPublish: z
|
||||
@@ -982,7 +996,7 @@ export const SubRoomDataSaveNoUnityAssetsDto = z.object({
|
||||
SubRoomId: z.int(),
|
||||
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
|
||||
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
|
||||
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
|
||||
DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'),
|
||||
DataBlobHash: z.string().nullable(),
|
||||
PersistenceVersion: z.int(),
|
||||
OMVersion: z.int(),
|
||||
@@ -1027,7 +1041,7 @@ export const SubRoomDataSaveDetailDto = z.object({
|
||||
SubRoomId: z.int(),
|
||||
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
|
||||
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
|
||||
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
|
||||
DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'),
|
||||
DataBlobHash: z.string().nullable(),
|
||||
PersistenceVersion: z.int(),
|
||||
OMVersion: z.int(),
|
||||
|
||||
+153
-47
@@ -16,6 +16,7 @@ import {
|
||||
cloneSubRoom,
|
||||
countRoomsByCreator,
|
||||
createNotification,
|
||||
createStudioRoom,
|
||||
createSubRoom,
|
||||
deleteExpiredRoomInvites,
|
||||
deleteRoom,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getStudioUnityAsset,
|
||||
getSubRoom,
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaveById,
|
||||
getSubRoomSaves,
|
||||
@@ -159,6 +159,7 @@ import {
|
||||
roomIdParam,
|
||||
RoomLookup,
|
||||
RoomPascalEnvelope,
|
||||
RoomRestrictionsEnvelope,
|
||||
RoomResultEnvelope,
|
||||
RoomRoleDto,
|
||||
RoomSaveEnvelope,
|
||||
@@ -933,11 +934,15 @@ function toSaveResponse(save: Record<string, unknown>, target: number | null = n
|
||||
unityAssetId: str(save.UnityAssetId),
|
||||
unityAsset: baked?.filename ?? null,
|
||||
unityAssetHash: baked?.hash ?? null,
|
||||
dataBlob: str(save.DataBlob) ?? '',
|
||||
dataBlob: str(save.DataBlob),
|
||||
dataBlobHash: str(save.DataBlobHash),
|
||||
persistenceVersion: num(save.PersistenceVersion) ?? 0,
|
||||
omVersion: num(save.OMVersion) ?? 0,
|
||||
ugcSubVersion: num(save.UgcSubVersion) ?? 0,
|
||||
savedByAccountId: num(save.SavedByAccountId),
|
||||
savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
|
||||
savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
|
||||
savedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0),
|
||||
savedOnDeviceClass:
|
||||
save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0),
|
||||
description: str(save.Description),
|
||||
createdAt: str(save.CreatedAt) ?? '',
|
||||
}
|
||||
@@ -1014,13 +1019,14 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
|
||||
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
|
||||
? save.ReferencedUnityAssetIds
|
||||
: [],
|
||||
DataBlob: str(save.DataBlob) ?? '',
|
||||
DataBlob: str(save.DataBlob),
|
||||
DataBlobHash: str(save.DataBlobHash),
|
||||
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
|
||||
OMVersion: num(save.OMVersion) ?? 0,
|
||||
SavedByAccountId: num(save.SavedByAccountId),
|
||||
SavedOnPlatform: num(save.SavedOnPlatform) ?? 0,
|
||||
SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
|
||||
SavedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0),
|
||||
SavedOnDeviceClass:
|
||||
save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0),
|
||||
Description: str(save.Description) ?? '',
|
||||
ModerationState: num(save.ModerationState) ?? 0,
|
||||
CreatedAt: str(save.CreatedAt) ?? '',
|
||||
@@ -1028,6 +1034,17 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A JSON authoring manifest is not interchangeable with a baked game bundle. */
|
||||
function studioSource(
|
||||
save: Record<string, unknown>
|
||||
): { Filename: string; Hash: string | null } | null {
|
||||
const value = save.StudioSourceAsset
|
||||
if (typeof value !== 'object' || value === null) return null
|
||||
const source = value as { Filename?: unknown; Hash?: unknown }
|
||||
if (typeof source.Filename !== 'string' || source.Filename === '') return null
|
||||
return { Filename: source.Filename, Hash: typeof source.Hash === 'string' ? source.Hash : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* One save as `GET …/subrooms/:sid/saves/:saveId` answers it — the PascalCase row the
|
||||
* `…/saves` list serves, NOT the camelCase `subRoomDataSave` the room save that created
|
||||
@@ -1035,10 +1052,10 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
|
||||
* values were all missing). Two things differ from the list row, both observed against
|
||||
* the reference:
|
||||
*
|
||||
* - Each `UnitySubAssets` entry is `{ UnityAssetId, UnityAsset, UnityAssetHash }` — the
|
||||
* stored `Filename`/`Hash` under the names the client's save-detail decoder reads —
|
||||
* and the save's own main bundle is lifted to top-level `UnityAsset`/`UnityAssetHash`
|
||||
* (the named target's, Windows when none was named; both null for a maker-pen save).
|
||||
* - The save's main bundle is at top-level `UnityAsset`/`UnityAssetHash`. Only other
|
||||
* bundles belong in `UnitySubAssets`, as `{ UnityAssetId, UnityAsset, UnityAssetHash }`.
|
||||
* Concrete filenames use the named target, Windows when absent; the main fields
|
||||
* are null for a maker-pen save.
|
||||
* - No `Tags`, and `UnityAssetId` is always present (null when the save carried none).
|
||||
*
|
||||
* Built key by key like {@link toSaveWithoutUnityAssets}, for the same reason.
|
||||
@@ -1047,11 +1064,18 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : null)
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : null)
|
||||
const main = pickBakedBundle(save, target)
|
||||
// The root bundle is loaded separately through UnityAsset. Listing it as a
|
||||
// dependency makes Unity load the same serialized files twice (room error 8714).
|
||||
// A detail response names concrete files, so default to Windows just as `main`
|
||||
// does; it cannot carry both platform builds under the same asset GUID.
|
||||
const rootId = str(save.UnityAssetId)?.toLowerCase()
|
||||
const bundles = (Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []).filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
(target === null || (item as { Target?: unknown }).Target === target)
|
||||
(item as { Target?: unknown }).Target === (target ?? 0) &&
|
||||
(typeof (item as { UnityAssetId?: unknown }).UnityAssetId !== 'string' ||
|
||||
(item as { UnityAssetId: string }).UnityAssetId.toLowerCase() !== rootId)
|
||||
)
|
||||
return {
|
||||
UnitySubAssets: bundles.map((row) => ({
|
||||
@@ -1070,7 +1094,7 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
|
||||
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
|
||||
? save.ReferencedUnityAssetIds
|
||||
: [],
|
||||
DataBlob: str(save.DataBlob) ?? '',
|
||||
DataBlob: str(save.DataBlob),
|
||||
DataBlobHash: str(save.DataBlobHash),
|
||||
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
|
||||
OMVersion: num(save.OMVersion) ?? 0,
|
||||
@@ -1121,7 +1145,9 @@ async function ownedRooms(c: Context<App>) {
|
||||
async function ownedRoomsExcludingDorm(c: Context<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const rooms = await getRoomsByCreator(c.env.DB, accountId)
|
||||
const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) =>
|
||||
canManageRoom(room, accountId)
|
||||
)
|
||||
return c.json(rooms.filter((r) => r.IsDorm !== true))
|
||||
}
|
||||
|
||||
@@ -1278,10 +1304,42 @@ const app = new Hono<App>()
|
||||
if (idParam) {
|
||||
const id = firstId(idParam)
|
||||
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
||||
return c.json(room ?? {})
|
||||
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
|
||||
}
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ?? {})
|
||||
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
|
||||
}
|
||||
)
|
||||
|
||||
// Studio creates an empty room directly, rather than cloning a game template.
|
||||
.post(
|
||||
'/rooms/create',
|
||||
describeRoute({
|
||||
tags: ['My rooms'],
|
||||
summary: 'Create an empty Studio room',
|
||||
security: AUTHED,
|
||||
requestBody: form(CloneRoomRequest, 'Room name and optional ugcVersion (1 or 2)'),
|
||||
responses: {
|
||||
200: json(RoomDto, 'The private room with its empty Home subroom'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const body = await c.req.parseBody()
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
const invalid = roomNameRejection(name, 'room name')
|
||||
if (name === '' || invalid !== null)
|
||||
return c.json({ error: invalid ?? 'A room name is required.' }, 400)
|
||||
const version = Number(body.ugcVersion ?? 1)
|
||||
if (version !== 1 && version !== 2) return c.json({ error: 'Unsupported UGC version.' }, 400)
|
||||
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
|
||||
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms)
|
||||
return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403)
|
||||
const room = await createStudioRoom(c.env.DB, name, accountId, version)
|
||||
if (!room) return c.json({ error: 'A room with that name already exists.' }, 409)
|
||||
return c.json(room)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1688,9 +1746,9 @@ const app = new Hono<App>()
|
||||
'/rooms/ownedby/me',
|
||||
describeRoute({
|
||||
tags: ['My rooms'],
|
||||
summary: 'Rooms the caller owns (excluding their dorm)',
|
||||
summary: 'Rooms the caller owns or co-owns (excluding their dorm)',
|
||||
description: [
|
||||
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
|
||||
'The caller’s created and accepted co-owned rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
|
||||
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
|
||||
'`createdby/me` for everything the account created. Accessibility is deliberately NOT',
|
||||
'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike',
|
||||
@@ -3553,8 +3611,9 @@ const app = new Hono<App>()
|
||||
// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
|
||||
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
|
||||
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/restrictions',
|
||||
.on(
|
||||
'PUT',
|
||||
['/rooms/:roomId{[0-9]+}/restrictions', '/rooms/:roomId{[0-9]+}/v2/restrictions'],
|
||||
describeRoute({
|
||||
tags: ['Room settings'],
|
||||
summary: 'Set a room’s platform/movement support flags',
|
||||
@@ -3567,7 +3626,10 @@ const app = new Hono<App>()
|
||||
parameters: [roomIdParam],
|
||||
requestBody: form(RestrictionsRequest, 'The flags to change'),
|
||||
responses: {
|
||||
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||
200: json(
|
||||
RoomRestrictionsEnvelope,
|
||||
'The updated room; Studio v2 uses a PascalCase result envelope'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
@@ -3584,7 +3646,7 @@ const app = new Hono<App>()
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const patch: Record<string, boolean> = {}
|
||||
const patch: Record<string, boolean | number> = {}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
||||
if (field !== undefined && typeof value === 'string') {
|
||||
@@ -3592,8 +3654,20 @@ const app = new Hono<App>()
|
||||
}
|
||||
}
|
||||
|
||||
const ageEntry = Object.entries(body).find(([key]) => key.toLowerCase() === 'agerating')
|
||||
if (ageEntry !== undefined) {
|
||||
const age = ageEntry[1]
|
||||
if (typeof age !== 'string' || !/^[012]$/.test(age)) {
|
||||
if (c.req.path.endsWith('/v2/restrictions'))
|
||||
return c.json({ Success: false, Error: 'Invalid age rating.', Value: null })
|
||||
return roomEnvelope(c, null, 'Invalid age rating.')
|
||||
}
|
||||
patch.AgeRating = Number(age)
|
||||
}
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
if (c.req.path.endsWith('/v2/restrictions'))
|
||||
return c.json({ Success: true, Error: null, Value: updated })
|
||||
return roomEnvelope(c, updated)
|
||||
}
|
||||
)
|
||||
@@ -3925,7 +3999,14 @@ const app = new Hono<App>()
|
||||
// `TotalResults` counts the whole history, not the page — that is what the client
|
||||
// pages against.
|
||||
return c.json({
|
||||
Results: page.map(toSaveWithoutUnityAssets),
|
||||
Results: page.map((save) => {
|
||||
const result = toSaveWithoutUnityAssets(save)
|
||||
// Archived game bundles have no editable Unity project. Studio can still
|
||||
// load their Maker Pen data; don't make it fetch a nonexistent source manifest.
|
||||
if (c.req.header('user-agent')?.startsWith('RRStudio') && !studioSource(save))
|
||||
result.UnityAssetId = null
|
||||
return result
|
||||
}),
|
||||
TotalResults: saves.length,
|
||||
TotalCount: saves.length,
|
||||
})
|
||||
@@ -4003,24 +4084,22 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Metadata for the unity asset on one of this subroom's saves. The editor calls
|
||||
// this after a build; the game reads the same bundles from `CurrentSave` and then
|
||||
// downloads `filename` from the CDN. Bare object, no envelope, no auth — the room
|
||||
// document already names the files.
|
||||
// The authoring manifest on a save. Its JSON source filename is independent of
|
||||
// the baked game bundles; a baked-only archive has no editable source to return.
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'The unity asset attached to a subroom save',
|
||||
description: [
|
||||
'The baked Windows and Android bundles stored for `unityAssetId`, when that id is',
|
||||
'The editable JSON source manifest and any baked bundles stored for `unityAssetId`, when that id is',
|
||||
'on a save of this subroom. A bare object: `unityAssetId`, `createdByAccountId`,',
|
||||
'`bakedUnityAssets` (one entry per main bundle), plus `filename` and `hash` for the',
|
||||
'Windows bundle. Bytes are `GET /unityasset/{filename}` on the CDN.',
|
||||
'authoring manifest. Source JSON is `GET /roomunityasset/{filename}` on the CDN; baked bundles remain separate.',
|
||||
'',
|
||||
'`target` is 0 for Windows and 2 for Android/Quest. Stripped bundles are stored and',
|
||||
'not listed. An unknown room, subroom, or asset is a 404. No auth: `GET /rooms/{id}`',
|
||||
'already includes the same filenames on `CurrentSave`.',
|
||||
'Stripped bundles are not listed. The published current save is public. Other revisions require the same',
|
||||
'authentication and permissions as save history. Without an asset ID, authorized',
|
||||
'readers receive the latest source; other callers receive only the published source.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
roomIdParam,
|
||||
@@ -4029,6 +4108,8 @@ const app = new Hono<App>()
|
||||
],
|
||||
responses: {
|
||||
200: json(UnityAssetWithSourceDto, 'The unity asset and its baked bundles'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: { description: 'No such room, subroom, or asset on that subroom' },
|
||||
},
|
||||
}),
|
||||
@@ -4036,27 +4117,40 @@ const app = new Hono<App>()
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const unityAssetId = c.req.query('unityAssetId') ?? ''
|
||||
if (unityAssetId === '') return c.notFound()
|
||||
|
||||
const sub = await getSubRoom(c.env.DB, roomId, subRoomId)
|
||||
if (!sub) return c.notFound()
|
||||
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
|
||||
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
const sub = room && findSubRoom(room, subRoomId)
|
||||
if (!room || !sub) return c.notFound()
|
||||
const current = sub.CurrentSave
|
||||
let save =
|
||||
typeof current === 'object' && current !== null
|
||||
? (current as Record<string, unknown>)
|
||||
: undefined
|
||||
const accountId = await authedAccountId(c)
|
||||
const canReadHistory = accountId !== null && (await canReadSaves(c, room, roomId, accountId))
|
||||
if (canReadHistory) {
|
||||
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
|
||||
save =
|
||||
unityAssetId === ''
|
||||
? saves.find((row) => studioSource(row) !== null)
|
||||
: saves.find((row) => row.UnityAssetId === unityAssetId)
|
||||
} else if (unityAssetId !== '' && save?.UnityAssetId !== unityAssetId) {
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.body(null, 403)
|
||||
}
|
||||
if (!save) return c.notFound()
|
||||
|
||||
const asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
|
||||
if (!asset) return c.notFound()
|
||||
|
||||
// Main bundles, Windows first; the Windows one (or the first there is) supplies
|
||||
// the top-level `filename`/`hash`.
|
||||
const mains = bakedUnityAssets(asset.builds)
|
||||
const source = mains.find((build) => build.Target === 0) ?? mains[0]
|
||||
const source = studioSource(save)
|
||||
if (!source) return c.notFound()
|
||||
|
||||
const asset = await getStudioUnityAsset(c.env.DB, String(save.UnityAssetId))
|
||||
|
||||
// Baked bundles are optional until compilation; the source exists before a build.
|
||||
const mains = bakedUnityAssets(asset?.builds ?? [])
|
||||
|
||||
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
|
||||
return c.json({
|
||||
unityAssetId: asset.unityAssetId,
|
||||
createdByAccountId: asset.createdByAccountId || savedBy,
|
||||
unityAssetId: String(save.UnityAssetId),
|
||||
createdByAccountId: asset?.createdByAccountId || savedBy,
|
||||
bakedUnityAssets: mains.map((build) => ({
|
||||
unityAssetId: build.UnityAssetId,
|
||||
target: build.Target,
|
||||
@@ -4132,8 +4226,11 @@ const app = new Hono<App>()
|
||||
RoomData?: { Filename?: string; Hash?: string | null }
|
||||
SubRoomData?: { Filename?: string; Hash?: string | null }
|
||||
UnityAssetId?: string | null
|
||||
UnityAsset?: { Filename?: string; Hash?: string | null }
|
||||
Description?: string
|
||||
PersistenceVersion?: number
|
||||
OMVersion?: number
|
||||
UgcSubVersion?: number
|
||||
InventionUsage?: string
|
||||
AutoPublish?: boolean
|
||||
}
|
||||
@@ -4145,10 +4242,19 @@ const app = new Hono<App>()
|
||||
roomDataFilename: body.RoomData?.Filename,
|
||||
roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined,
|
||||
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
|
||||
unitySource:
|
||||
typeof body.UnityAsset?.Filename === 'string' && body.UnityAsset.Filename !== ''
|
||||
? {
|
||||
Filename: body.UnityAsset.Filename,
|
||||
Hash: typeof body.UnityAsset.Hash === 'string' ? body.UnityAsset.Hash : null,
|
||||
}
|
||||
: undefined,
|
||||
autoPublish: body.AutoPublish === true,
|
||||
description: typeof body.Description === 'string' ? body.Description : undefined,
|
||||
persistenceVersion:
|
||||
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
||||
omVersion: typeof body.OMVersion === 'number' ? body.OMVersion : undefined,
|
||||
ugcSubVersion: typeof body.UgcSubVersion === 'number' ? body.UgcSubVersion : undefined,
|
||||
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
|
||||
})
|
||||
if (!result) {
|
||||
|
||||
@@ -3744,6 +3744,9 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
|
||||
expect(saved.value.subRoomDataSave).toEqual({
|
||||
persistenceVersion: 41,
|
||||
omVersion: 0,
|
||||
ugcSubVersion: 0,
|
||||
subRoomDataSaveId: expect.any(Number),
|
||||
subRoomId: 2,
|
||||
unityAssetId: null,
|
||||
@@ -6076,6 +6079,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/{roomId}/bans',
|
||||
'GET /rooms/{roomId}/bans/history',
|
||||
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
|
||||
'GET /rooms/{roomId}/boosts/{playerId}/count',
|
||||
'GET /rooms/{roomId}/experience',
|
||||
'GET /rooms/{roomId}/experience/player',
|
||||
'GET /rooms/{roomId}/interactionby/me',
|
||||
@@ -6089,6 +6093,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /roomserver/rooms/createdby/me',
|
||||
'GET /showcase/{playerId}',
|
||||
'POST /rooms/bulk',
|
||||
'POST /rooms/create',
|
||||
'POST /rooms/{roomId}/bans',
|
||||
'POST /rooms/{roomId}/clone',
|
||||
'POST /rooms/{roomId}/experience',
|
||||
@@ -6119,6 +6124,7 @@ describe('rooms endpoints', () => {
|
||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
|
||||
'PUT /rooms/{roomId}/tags',
|
||||
'PUT /rooms/{roomId}/v2/restrictions',
|
||||
'PUT /rooms/{roomId}/warning',
|
||||
])
|
||||
|
||||
@@ -6135,7 +6141,7 @@ describe('rooms endpoints', () => {
|
||||
// `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
|
||||
// table must not fail the room read.
|
||||
describe('studio room bundles', () => {
|
||||
const ASSET = '11111111-2222-4333-8444-555555555555'
|
||||
const ASSET = 'aa111111-2222-4333-8444-555555555555'
|
||||
const winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main')
|
||||
const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
|
||||
const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
||||
@@ -6157,7 +6163,12 @@ describe('studio room bundles', () => {
|
||||
Tags: [],
|
||||
ModerationState: 0,
|
||||
CreatedAt: '2026-10-03T00:00:00.000Z',
|
||||
...(unityAssetId ? { UnityAssetId: unityAssetId } : {}),
|
||||
...(unityAssetId
|
||||
? {
|
||||
UnityAssetId: unityAssetId,
|
||||
StudioSourceAsset: { Filename: 'source-manifest.json', Hash: 'source-hash' },
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
async function reset() {
|
||||
@@ -6294,20 +6305,24 @@ describe('studio room bundles', () => {
|
||||
{ unityAssetId: ASSET, target: 0, version: 1, filename: winName },
|
||||
{ unityAssetId: ASSET, target: 2, version: 1, filename: androidName },
|
||||
],
|
||||
filename: winName,
|
||||
hash: winHash,
|
||||
filename: 'source-manifest.json',
|
||||
hash: 'source-hash',
|
||||
})
|
||||
expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(404)
|
||||
expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(200)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000`
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
).status
|
||||
).toBe(404)
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`))
|
||||
.status
|
||||
(
|
||||
await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`, {
|
||||
headers: await bearer('1'),
|
||||
})
|
||||
).status
|
||||
).toBe(404)
|
||||
|
||||
const detail = await SELF.fetch(
|
||||
@@ -6316,15 +6331,12 @@ describe('studio room bundles', () => {
|
||||
)
|
||||
expect(detail.status).toBe(200)
|
||||
// The detail lifts the Windows bundle to the top level when no target is named, and
|
||||
// names every bundle the client's way — `UnityAsset`/`UnityAssetHash`, not the stored
|
||||
// `Filename`/`Hash`.
|
||||
// lists only dependencies in UnitySubAssets. Repeating the root there makes the
|
||||
// game load it twice and Unity rejects the second load with room error 8714.
|
||||
expect(await detail.json()).toMatchObject({
|
||||
UnityAsset: winName,
|
||||
UnityAssetHash: winHash,
|
||||
UnitySubAssets: [
|
||||
{ UnityAssetId: ASSET, UnityAsset: winName, UnityAssetHash: winHash },
|
||||
{ UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash },
|
||||
],
|
||||
UnitySubAssets: [],
|
||||
})
|
||||
|
||||
const questDetail = await SELF.fetch(
|
||||
@@ -6334,9 +6346,7 @@ describe('studio room bundles', () => {
|
||||
expect(await questDetail.json()).toMatchObject({
|
||||
UnityAsset: androidName,
|
||||
UnityAssetHash: androidHash,
|
||||
UnitySubAssets: [
|
||||
{ UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash },
|
||||
],
|
||||
UnitySubAssets: [],
|
||||
})
|
||||
|
||||
const light = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/no_unity_assets`, {
|
||||
@@ -6358,6 +6368,35 @@ describe('studio room bundles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('never loads the main bundle again as a dependency, including mixed-case GUIDs', async () => {
|
||||
await reset()
|
||||
await storeFiles()
|
||||
await env.DB.prepare(
|
||||
"UPDATE subroom_save SET data=json_set(data, '$.UnityAssetId', ?1) WHERE sub_room_id=99011"
|
||||
)
|
||||
.bind(ASSET.toUpperCase())
|
||||
.run()
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT current_save_id FROM subroom WHERE sub_room_id=99011'
|
||||
).first<{ current_save_id: number }>()
|
||||
for (const [query, filename] of [
|
||||
['', winName],
|
||||
['?unityAssetTarget=0', winName],
|
||||
['?unityAssetTarget=2', androidName],
|
||||
['?unityAssetTarget=1', null],
|
||||
] as const) {
|
||||
const response = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${row!.current_save_id}${query}`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
UnityAsset: filename,
|
||||
UnitySubAssets: [],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// A FIRST-PARTY room's bundle — captured from the official game and loaded by hand
|
||||
// under its `unity/<roomId>/` layout — shares `unity_asset` with Studio builds but is
|
||||
// not UGC, and must not be folded onto the save: the 2025 client handed it inline
|
||||
@@ -6682,3 +6721,378 @@ describe('Studio baked Unity asset downloads', () => {
|
||||
expect(await (await bulk({ UnityAssetIds: [mainId] })).json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Studio room management contracts', () => {
|
||||
it('lists private co-owned rooms, but excludes hosts and pending invites', async () => {
|
||||
for (const [id, role, invited] of [
|
||||
[91001, 30, 0],
|
||||
[91002, 10, 0],
|
||||
[91003, 0, 30],
|
||||
]) {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: id,
|
||||
Name: `StudioRole${id}`,
|
||||
CreatorAccountId: 999,
|
||||
Accessibility: 0,
|
||||
Roles: [{ AccountId: 880, Role: role, InvitedRole: invited }],
|
||||
SubRooms: [],
|
||||
})
|
||||
}
|
||||
const rooms = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('880') })
|
||||
).json()) as Array<{ RoomId: number }>
|
||||
expect(rooms.map((r) => r.RoomId)).toEqual([91001])
|
||||
expect(
|
||||
await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/createdby/me`, { headers: await bearer('880') })
|
||||
).json()
|
||||
).toEqual([])
|
||||
})
|
||||
it('returns null for Studio name validation and preserves the legacy empty object', async () => {
|
||||
expect(
|
||||
await (await SELF.fetch(`${ORIGIN}/rooms?name=StudioUnusedName&include=525581`)).json()
|
||||
).toBeNull()
|
||||
expect(await (await SELF.fetch(`${ORIGIN}/rooms?name=StudioUnusedName`)).json()).toEqual({})
|
||||
})
|
||||
it('creates a private empty R2 room and rejects a case-insensitive duplicate', async () => {
|
||||
const create = async (name: string, auth = true) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(auth ? await bearer('880') : {}),
|
||||
},
|
||||
body: new URLSearchParams({ name, ugcVersion: '2' }).toString(),
|
||||
})
|
||||
expect((await create('StudioFreshRoom', false)).status).toBe(401)
|
||||
const response = await create('StudioFreshRoom')
|
||||
expect(response.status).toBe(200)
|
||||
const room = (await response.json()) as Room
|
||||
expect(room).toMatchObject({
|
||||
Name: 'StudioFreshRoom',
|
||||
UgcVersion: 2,
|
||||
Accessibility: 0,
|
||||
CreatorAccountId: 880,
|
||||
Tags: [],
|
||||
LoadScreens: [],
|
||||
RestrictedCircuitsAllowListNames: [],
|
||||
PublishedAt: null,
|
||||
})
|
||||
expect(room.SubRooms).toEqual([
|
||||
expect.objectContaining({
|
||||
Name: 'Home',
|
||||
CreatorAccountId: 880,
|
||||
CurrentSave: expect.objectContaining({ DataBlob: null, OMVersion: 198 }),
|
||||
UnitySceneId: '5d4e40d8-f289-4295-a6e1-4f907835007d',
|
||||
}),
|
||||
])
|
||||
const added = await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) },
|
||||
body: 'name=Second',
|
||||
})
|
||||
expect(added.status).toBe(200)
|
||||
const lookup = await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}?include=525581`)
|
||||
const refreshed = await lookup.json<
|
||||
Room & { SubRooms: Array<{ SubRoomId: number; CurrentSave: unknown }> }
|
||||
>()
|
||||
expect(refreshed.SubRooms).toHaveLength(2)
|
||||
for (const sub of refreshed.SubRooms) {
|
||||
expect(sub.CurrentSave).toMatchObject({ DataBlob: null, OMVersion: 198 })
|
||||
}
|
||||
const initial = refreshed.SubRooms[0]!.CurrentSave as { SubRoomDataSaveId: number }
|
||||
const subRoomId = Number(refreshed.SubRooms[0]!.SubRoomId)
|
||||
const initialSave = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms/${subRoomId}/saves/${initial.SubRoomDataSaveId}`,
|
||||
{ headers: await bearer('880') }
|
||||
)
|
||||
expect(initialSave.status).toBe(200)
|
||||
expect(await initialSave.json()).toMatchObject({ DataBlob: null, OMVersion: 198 })
|
||||
for (const fields of [{ OMVersion: 197, UgcSubVersion: 3 }, {}]) {
|
||||
const saved = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms/${subRoomId}/data`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('880')) },
|
||||
body: JSON.stringify({ ...fields, AutoPublish: true }),
|
||||
}
|
||||
)
|
||||
expect(saved.status).toBe(200)
|
||||
const updated = await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}?include=525581`)
|
||||
).json<Room & { SubRooms: Array<{ CurrentSave: unknown }> }>()
|
||||
expect(updated.SubRooms[0]!.CurrentSave).toMatchObject({ OMVersion: 197, UgcSubVersion: 3 })
|
||||
}
|
||||
expect((await create('studiofreshroom')).status).toBe(409)
|
||||
expect((await create('bad name')).status).toBe(400)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer('880'),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
})
|
||||
it('keeps fresh R1 rooms owned and their new subrooms visible to the Studio picker', async () => {
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) },
|
||||
body: new URLSearchParams({ name: 'StudioFreshR1', ugcVersion: '1' }).toString(),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const created = await response.json<Room>()
|
||||
const roomId = Number(created.RoomId)
|
||||
expect(created.Roles).toEqual([expect.objectContaining({ AccountId: 880, Role: 255 })])
|
||||
const added = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) },
|
||||
body: 'name=Second',
|
||||
})
|
||||
expect(added.status).toBe(200)
|
||||
const lookup = await SELF.fetch(`${ORIGIN}/rooms?name=StudioFreshR1&include=525581`)
|
||||
const room = await lookup.json<Room & { SubRooms: Array<{ SubRoomId: number }> }>()
|
||||
expect(room.SubRooms).toEqual([
|
||||
expect.objectContaining({
|
||||
Name: 'Home',
|
||||
CurrentSave: null,
|
||||
UnitySceneId: 'a75f7547-79eb-47c6-8986-6767abcb4f92',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
Name: 'Second',
|
||||
CurrentSave: null,
|
||||
UnitySceneId: 'a75f7547-79eb-47c6-8986-6767abcb4f92',
|
||||
}),
|
||||
])
|
||||
for (const path of ['/rooms/ownedby/me', '/rooms/createdby/me']) {
|
||||
const rooms = await (
|
||||
await SELF.fetch(ORIGIN + path, { headers: await bearer('880') })
|
||||
).json<Room[]>()
|
||||
expect(rooms.some((entry) => entry.RoomId === created.RoomId)).toBe(true)
|
||||
}
|
||||
const history = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/${roomId}/subrooms/${room.SubRooms[0]!.SubRoomId}/saves/no_unity_assets`,
|
||||
{ headers: await bearer('880') }
|
||||
)
|
||||
expect(history.status).toBe(200)
|
||||
expect(await history.json()).toMatchObject({ Results: [] })
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(`${ORIGIN}/rooms/${roomId}`, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer('880'),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
})
|
||||
|
||||
it('saves the Studio age rating through v2 restrictions with ownership checks', async () => {
|
||||
const before = await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json<Room>()
|
||||
const update = async (body: string, account?: string) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/2/v2/restrictions`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(account ? await bearer(account) : {}),
|
||||
},
|
||||
body,
|
||||
})
|
||||
expect((await update('ageRating=1')).status).toBe(401)
|
||||
expect((await update('ageRating=1', '999')).status).toBe(403)
|
||||
const result = await update('ageRating=1&supportsScreens=True&supportsWalkVR=False', '1')
|
||||
expect(result.status).toBe(200)
|
||||
expect(await result.json()).toMatchObject({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: {
|
||||
AgeRating: 1,
|
||||
SupportsScreens: true,
|
||||
SupportsWalkVR: false,
|
||||
LoadScreens: before.LoadScreens,
|
||||
},
|
||||
})
|
||||
const invalid = await update('ageRating=99', '1')
|
||||
expect(await invalid.json()).toEqual({
|
||||
Success: false,
|
||||
Error: 'Invalid age rating.',
|
||||
Value: null,
|
||||
})
|
||||
expect((await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json<Room>()).AgeRating).toBe(1)
|
||||
await update('ageRating=2&supportsWalkVR=True', '1')
|
||||
})
|
||||
|
||||
it('does not advertise an archived baked asset as editable source', async () => {
|
||||
const id = '11111111-2222-4333-8444-555555555555'
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 91004,
|
||||
Name: 'StudioArchivedGame',
|
||||
CreatorAccountId: 880,
|
||||
Roles: [],
|
||||
SubRooms: [
|
||||
{
|
||||
SubRoomId: 91004,
|
||||
Name: 'Home',
|
||||
CurrentSave: {
|
||||
UnityAssetId: id,
|
||||
DataBlob: 'original-game.room',
|
||||
SavedOnPlatform: 0,
|
||||
SavedOnDeviceClass: 2,
|
||||
SavedByAccountId: 880,
|
||||
CreatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const headers = await bearer('880')
|
||||
const url = `${ORIGIN}/rooms/91004/subrooms/91004/saves/no_unity_assets`
|
||||
const studio = (await (
|
||||
await SELF.fetch(url, { headers: { ...headers, 'User-Agent': 'RRStudio' } })
|
||||
).json()) as { Results: Array<Record<string, unknown>> }
|
||||
expect(studio.Results[0]).toMatchObject({ UnityAssetId: null, DataBlob: 'original-game.room' })
|
||||
const legacy = (await (await SELF.fetch(url, { headers })).json()) as typeof studio
|
||||
expect(legacy.Results[0]!.UnityAssetId).toBe(id)
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/91004/subrooms/91004/unityasset?unityAssetId=${id}`))
|
||||
.status
|
||||
).toBe(404)
|
||||
const original = (await (await SELF.fetch(`${ORIGIN}/rooms/91004`)).json()) as {
|
||||
SubRooms: Array<{ CurrentSave: Record<string, unknown> }>
|
||||
}
|
||||
expect(original.SubRooms[0]!.CurrentSave.UnityAssetId).toBe(id)
|
||||
})
|
||||
it('persists source manifests and returns them independently of baked builds', async () => {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 91005,
|
||||
Name: 'StudioSourceRoom',
|
||||
CreatorAccountId: 880,
|
||||
Roles: [],
|
||||
SubRooms: [{ SubRoomId: 91005, Name: 'Home', CurrentSave: null }],
|
||||
})
|
||||
const id = '22222222-3333-4444-8555-666666666666'
|
||||
const saved = await SELF.fetch(`${ORIGIN}/rooms/91005/subrooms/91005/data`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('880')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
UnityAssetId: id,
|
||||
UnityAsset: { Filename: 'manifest.json', Hash: 'source-hash' },
|
||||
SubRoomData: { Filename: 'maker.room' },
|
||||
AutoPublish: true,
|
||||
}),
|
||||
})
|
||||
expect(saved.status).toBe(200)
|
||||
const source = await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/91005/subrooms/91005/unityasset?unityAssetId=${id}`)
|
||||
).json()
|
||||
expect(source).toMatchObject({
|
||||
unityAssetId: id,
|
||||
filename: 'manifest.json',
|
||||
hash: 'source-hash',
|
||||
bakedUnityAssets: [],
|
||||
})
|
||||
const history = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/91005/subrooms/91005/saves/no_unity_assets`, {
|
||||
headers: { ...(await bearer('880')), 'User-Agent': 'RRStudio' },
|
||||
})
|
||||
).json()) as { Results: Array<Record<string, unknown>> }
|
||||
expect(history.Results[0]).toMatchObject({
|
||||
UnityAssetId: id,
|
||||
SavedOnPlatform: null,
|
||||
SavedOnDeviceClass: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Studio source revision permissions', () => {
|
||||
it('keeps drafts and historical source behind save-history permissions', async () => {
|
||||
const roomId = 987650
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: roomId,
|
||||
Name: 'PrivateStudioDraft',
|
||||
CreatorAccountId: 987650,
|
||||
Accessibility: 0,
|
||||
Roles: [{ AccountId: 987651, Role: 30 }],
|
||||
SubRooms: [{ SubRoomId: roomId, Name: 'Home', CurrentSave: null }],
|
||||
})
|
||||
const url = `${ORIGIN}/rooms/${roomId}/subrooms/${roomId}/unityasset`
|
||||
const save = async (id: string, filename: string, autoPublish: boolean) => {
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${roomId}/data`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(String(roomId))), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
UnityAssetId: id,
|
||||
UnityAsset: { Filename: filename, Hash: 'source-hash' },
|
||||
SubRoomData: { Filename: 'draft.room' },
|
||||
AutoPublish: autoPublish,
|
||||
}),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
}
|
||||
const oldId = '11111111-3333-4444-8555-666666666666'
|
||||
const currentId = '22222222-3333-4444-8555-666666666666'
|
||||
const draftId = '33333333-3333-4444-8555-666666666666'
|
||||
await save(oldId, 'old.json', true)
|
||||
await save(currentId, 'published.json', true)
|
||||
await save(draftId, 'draft.json', false)
|
||||
for (const headers of [{}, await bearer('987652')]) {
|
||||
for (const query of ['', `?unityAssetId=${currentId}`]) {
|
||||
const response = await SELF.fetch(url + query, { headers })
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ filename: 'published.json' })
|
||||
}
|
||||
}
|
||||
for (const id of [draftId, oldId]) {
|
||||
expect((await SELF.fetch(url + `?unityAssetId=${id}`)).status).toBe(401)
|
||||
expect(
|
||||
(await SELF.fetch(url + `?unityAssetId=${id}`, { headers: await bearer('987652') })).status
|
||||
).toBe(403)
|
||||
for (const account of ['987650', '987651']) {
|
||||
const response = await SELF.fetch(url + `?unityAssetId=${id}`, {
|
||||
headers: await bearer(account),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
filename: id === draftId ? 'draft.json' : 'old.json',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const account of ['987650', '987651']) {
|
||||
const response = await SELF.fetch(url, { headers: await bearer(account) })
|
||||
expect(await response.json()).toMatchObject({ filename: 'draft.json' })
|
||||
}
|
||||
// A reused asset ID must not make a newer draft public or hide it from its owner.
|
||||
await save(currentId, 'replacement-draft.json', false)
|
||||
const publicSource = await SELF.fetch(url + `?unityAssetId=${currentId}`)
|
||||
expect(await publicSource.json()).toMatchObject({ filename: 'published.json' })
|
||||
const ownerSource = await SELF.fetch(url + `?unityAssetId=${currentId}`, {
|
||||
headers: await bearer('987650'),
|
||||
})
|
||||
expect(await ownerSource.json()).toMatchObject({ filename: 'replacement-draft.json' })
|
||||
})
|
||||
it('does not expose an unpublished first revision through default lookup', async () => {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 987660,
|
||||
Name: 'UnpublishedStudioSource',
|
||||
CreatorAccountId: 987660,
|
||||
Roles: [],
|
||||
SubRooms: [{ SubRoomId: 987660, Name: 'Home', CurrentSave: null }],
|
||||
})
|
||||
const url = `${ORIGIN}/rooms/987660/subrooms/987660`
|
||||
const saved = await SELF.fetch(url + '/data', {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('987660')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
UnityAssetId: '44444444-3333-4444-8555-666666666666',
|
||||
UnityAsset: { Filename: 'first-draft.json', Hash: null },
|
||||
AutoPublish: false,
|
||||
}),
|
||||
})
|
||||
expect(saved.status).toBe(200)
|
||||
expect((await SELF.fetch(url + '/unityasset')).status).toBe(404)
|
||||
expect(
|
||||
(await SELF.fetch(url + '/unityasset', { headers: await bearer('987661') })).status
|
||||
).toBe(404)
|
||||
const source = await SELF.fetch(url + '/unityasset', { headers: await bearer('987660') })
|
||||
expect(source.status).toBe(200)
|
||||
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
+158
-25
@@ -845,6 +845,94 @@ export async function cloneRoom(
|
||||
return cloned
|
||||
}
|
||||
|
||||
/** Create an empty Studio room, reserving its id and name in one write. */
|
||||
export async function createStudioRoom(
|
||||
db: D1Database,
|
||||
name: string,
|
||||
accountId: number,
|
||||
ugcVersion: 1 | 2
|
||||
): Promise<Room | null> {
|
||||
const room: Room = {
|
||||
RoomId: 0,
|
||||
Name: name,
|
||||
Description: '',
|
||||
ImageName: '',
|
||||
CreatorAccountId: accountId,
|
||||
Accessibility: Accessibility.Private,
|
||||
State: 0,
|
||||
PublishState: 0,
|
||||
WarningMask: 0,
|
||||
CustomWarning: null,
|
||||
SupportsScreens: true,
|
||||
SupportsWalkVR: true,
|
||||
SupportsTeleportVR: true,
|
||||
SupportsVRLow: true,
|
||||
SupportsQuest2: true,
|
||||
SupportsMobile: true,
|
||||
SupportsJuniors: true,
|
||||
SupportsLevelVoting: false,
|
||||
IsRRO: false,
|
||||
IsRecRoomApproved: false,
|
||||
IsDorm: false,
|
||||
IsPlacePlay: false,
|
||||
IsDeveloperOwned: false,
|
||||
IsJuniorCreated: false,
|
||||
ExcludeFromLists: false,
|
||||
ExcludeFromSearch: false,
|
||||
CloningAllowed: false,
|
||||
MinLevel: 0,
|
||||
AgeRating: 0,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
PublishedAt: null,
|
||||
BecameRRStudioRoomAt: null,
|
||||
Stats: { ...ZERO_STATS },
|
||||
UgcVersion: ugcVersion,
|
||||
PersistenceVersion: 0,
|
||||
UgcSubVersion: null,
|
||||
MinUgcSubVersion: null,
|
||||
MaxPlayers: 4,
|
||||
MaxPlayerCalculationMode: 0,
|
||||
DisableMicAutoMute: false,
|
||||
DisableRoomComments: false,
|
||||
EncryptVoiceChat: false,
|
||||
ToxmodEnabled: false,
|
||||
LoadScreenLocked: false,
|
||||
AutoLocalizeRoom: false,
|
||||
LocalizationContext: {},
|
||||
RankedEntityId: '',
|
||||
Roles: [
|
||||
{
|
||||
AccountId: accountId,
|
||||
Role: Role.Creator,
|
||||
LastChangedByAccountId: null,
|
||||
InvitedRole: Role.None,
|
||||
},
|
||||
],
|
||||
Tags: [],
|
||||
SubRooms: [],
|
||||
}
|
||||
const inserted = await db
|
||||
.prepare(
|
||||
`INSERT INTO room (data)
|
||||
SELECT json_set(?1, '$.RoomId', COALESCE(MAX(room_id), 0) + 1) FROM room
|
||||
HAVING NOT EXISTS (SELECT 1 FROM room WHERE name_lower = lower(?2))
|
||||
RETURNING data`
|
||||
)
|
||||
.bind(serializeRoom(room), name)
|
||||
.first<{ data: string }>()
|
||||
if (!inserted) return null
|
||||
const created = JSON.parse(inserted.data) as Room
|
||||
// Studio's picker and empty-save loader support these modern Maker Room scenes,
|
||||
// not the legacy sandbox scene used by generic game subroom creation.
|
||||
const scene =
|
||||
ugcVersion === 2
|
||||
? '5d4e40d8-f289-4295-a6e1-4f907835007d'
|
||||
: 'a75f7547-79eb-47c6-8986-6767abcb4f92'
|
||||
return (
|
||||
(await createSubRoom(db, Number(created.RoomId), accountId, 'Home', scene))?.room ?? created
|
||||
)
|
||||
}
|
||||
|
||||
/** Set a room's Description in place (the caller is responsible for the owner check). */
|
||||
export async function setRoomDescription(
|
||||
db: D1Database,
|
||||
@@ -1439,9 +1527,13 @@ export interface SaveSubRoomDataInput {
|
||||
*/
|
||||
description?: string
|
||||
persistenceVersion?: number
|
||||
omVersion?: number
|
||||
ugcSubVersion?: number
|
||||
inventionUsage?: string
|
||||
/** Optional baked-asset id; emitted on the save only when present. */
|
||||
unityAssetId?: string
|
||||
/** Uploaded JSON authoring manifest, distinct from a playable baked bundle. */
|
||||
unitySource?: { Filename: string; Hash: string | null }
|
||||
/**
|
||||
* The client's `AutoPublish`. True publishes the save outright (the author wants it
|
||||
* live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and
|
||||
@@ -1476,9 +1568,11 @@ export function subRoomDataBlob(sub: SubRoom | undefined | null): string {
|
||||
/** Fields that vary between a real save and one reconstructed from the legacy shape. */
|
||||
interface BuildSaveInput {
|
||||
subRoomId: unknown
|
||||
dataBlob: string
|
||||
dataBlob: string | null
|
||||
dataBlobHash: string | null
|
||||
persistenceVersion: number
|
||||
omVersion?: number
|
||||
ugcSubVersion?: number
|
||||
savedByAccountId: unknown
|
||||
description: string
|
||||
createdAt: string
|
||||
@@ -1511,8 +1605,8 @@ function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave {
|
||||
DataBlobHash: input.dataBlobHash,
|
||||
ReferencedUnityAssetIds: [],
|
||||
PersistenceVersion: input.persistenceVersion,
|
||||
OMVersion: 0,
|
||||
UgcSubVersion: 0,
|
||||
OMVersion: input.omVersion ?? 0,
|
||||
UgcSubVersion: input.ugcSubVersion ?? 0,
|
||||
SavedByAccountId: input.savedByAccountId,
|
||||
SavedOnPlatform: 0,
|
||||
SavedOnDeviceClass: 0,
|
||||
@@ -1745,25 +1839,34 @@ export async function saveSubRoomData(
|
||||
: undefined)
|
||||
const priorVersion = previous?.PersistenceVersion
|
||||
const priorBlob = previous?.DataBlob
|
||||
const save = await insertSubRoomSave(
|
||||
db,
|
||||
const saveData = buildSubRoomSave({
|
||||
subRoomId,
|
||||
buildSubRoomSave({
|
||||
subRoomId,
|
||||
// A save that carries no new blob (e.g. a description-only save) keeps the one
|
||||
// the subroom already loads from.
|
||||
dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''),
|
||||
dataBlobHash: input.subRoomDataHash ?? null,
|
||||
persistenceVersion:
|
||||
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
|
||||
savedByAccountId: accountId,
|
||||
// The save comment — empty string, not null, when the save carries none (the
|
||||
// reference's `roomDesc ?? ""`).
|
||||
description: input.description ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
unityAssetId: input.unityAssetId,
|
||||
})
|
||||
)
|
||||
// A save that carries no new blob (e.g. a description-only save) keeps the one
|
||||
// the subroom already loads from.
|
||||
dataBlob:
|
||||
input.subRoomDataFilename ??
|
||||
(typeof priorBlob === 'string' || priorBlob === null ? priorBlob : ''),
|
||||
dataBlobHash: input.subRoomDataHash ?? null,
|
||||
persistenceVersion:
|
||||
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
|
||||
omVersion:
|
||||
input.omVersion ?? (typeof previous?.OMVersion === 'number' ? previous.OMVersion : 0),
|
||||
ugcSubVersion:
|
||||
input.ugcSubVersion ??
|
||||
(typeof previous?.UgcSubVersion === 'number' ? previous.UgcSubVersion : 0),
|
||||
savedByAccountId: accountId,
|
||||
// The save comment — empty string, not null, when the save carries none (the
|
||||
// reference's `roomDesc ?? ""`).
|
||||
description: input.description ?? '',
|
||||
createdAt: new Date().toISOString(),
|
||||
unityAssetId: input.unityAssetId,
|
||||
})
|
||||
if (input.unitySource) {
|
||||
saveData.StudioSourceAsset = input.unitySource
|
||||
saveData.SavedOnPlatform = null
|
||||
saveData.SavedOnDeviceClass = null
|
||||
}
|
||||
const save = await insertSubRoomSave(db, subRoomId, saveData)
|
||||
const saveId = Number(save.SubRoomDataSaveId)
|
||||
sub.DataSavedAt = new Date().toISOString()
|
||||
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
|
||||
@@ -1951,7 +2054,8 @@ export async function createSubRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
accountId: number,
|
||||
name: string
|
||||
name: string,
|
||||
initialScene?: string
|
||||
): Promise<{ room: Room; subRoom: SubRoom } | null> {
|
||||
const room = await getRoomById(db, roomId)
|
||||
if (!room) return null
|
||||
@@ -1959,14 +2063,29 @@ export async function createSubRoom(
|
||||
const subRoom = await insertSubRoom(db, roomId, {
|
||||
Name: name,
|
||||
CreatorAccountId: accountId,
|
||||
UnitySceneId: await baseSubRoomScene(db, roomId),
|
||||
UnitySceneId: initialScene ?? (await baseSubRoomScene(db, roomId)),
|
||||
MaxPlayers: 4,
|
||||
Accessibility: Accessibility.Unlisted,
|
||||
IsSandbox: true,
|
||||
LastModeratedSaveModerationState: 0,
|
||||
ShouldAutoStageSaves: true,
|
||||
// Nothing saved yet — the first room save mints one and points current_save_id
|
||||
// at it. Until then the subroom reads with `CurrentSave: null`.
|
||||
// Rooms 2 validates CurrentSave.OMVersion even when the scene uses its built-in
|
||||
// empty template. No blob or uploaded Studio bundle is needed for that template.
|
||||
...(room.UgcVersion === 2
|
||||
? {
|
||||
CurrentSave: buildSubRoomSave({
|
||||
subRoomId: 0,
|
||||
dataBlob: null,
|
||||
dataBlobHash: null,
|
||||
persistenceVersion: 0,
|
||||
// 20260323.14's built-in object-model template format (0xC6).
|
||||
omVersion: 198,
|
||||
savedByAccountId: accountId,
|
||||
description: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// Refresh the hydrated SubRooms so the returned room includes the one just inserted.
|
||||
await attachSubRooms(db, [room])
|
||||
@@ -2706,6 +2825,20 @@ async function hydrateRooms(
|
||||
rooms: Room[],
|
||||
stats?: Map<number, RoomStats>
|
||||
): Promise<Room[]> {
|
||||
for (const room of rooms) {
|
||||
// Studio enumerates these collections directly when opening Room Settings.
|
||||
for (const field of [
|
||||
'LoadScreens',
|
||||
'PromoImages',
|
||||
'PromoExternalContent',
|
||||
'RestrictedCircuitsAllowListNames',
|
||||
]) {
|
||||
if (!Array.isArray(room[field])) room[field] = []
|
||||
}
|
||||
// An unpublished room has no publication date; an empty string fails the
|
||||
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
||||
if (room.PublishedAt === '') room.PublishedAt = null
|
||||
}
|
||||
await Promise.all([
|
||||
attachSubRooms(db, rooms),
|
||||
attachTags(db, rooms),
|
||||
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
validateAndGetRoles,
|
||||
validateAndGetVersion,
|
||||
generateToken,
|
||||
generateStudioBuildValidationToken,
|
||||
validateStudioBuildValidationToken,
|
||||
generatePhotonAuthToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
|
||||
@@ -18,6 +18,43 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
|
||||
// last a day so the client doesn't have to refresh it.
|
||||
export const TOKEN_TTL_SECONDS = 86400
|
||||
|
||||
/** A build host can check component text, but cannot act as an account. */
|
||||
export async function generateStudioBuildValidationToken(
|
||||
buildId: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
aud: 'studio-build-validation',
|
||||
scope: 'studio.build.validate',
|
||||
buildId,
|
||||
exp: Math.floor(Date.now() / 1000) + 8 * 3600,
|
||||
},
|
||||
secret,
|
||||
'HS256'
|
||||
)
|
||||
}
|
||||
|
||||
export async function validateStudioBuildValidationToken(
|
||||
request: Request,
|
||||
secret: string
|
||||
): Promise<boolean> {
|
||||
const header = request.headers.get('Authorization') ?? ''
|
||||
if (!header.startsWith('Bearer ')) return false
|
||||
try {
|
||||
const payload = await verify(header.slice(7), secret, 'HS256')
|
||||
return (
|
||||
payload.sub === undefined &&
|
||||
payload.aud === 'studio-build-validation' &&
|
||||
payload.scope === 'studio.build.validate' &&
|
||||
typeof payload.buildId === 'string' &&
|
||||
/^[1-9]\d*$/.test(payload.buildId)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an HS256 token and return its `sub` (account id) claim, or `null` when
|
||||
* the token is malformed, has a bad signature, or is expired/not-yet-valid.
|
||||
|
||||
Reference in New Issue
Block a user