mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -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:
|
- 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
|
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
|
||||||
`subRoomDataSave` is camelCase with a different field set from the PascalCase
|
`subRoomDataSave` is camelCase with a different field set from the PascalCase
|
||||||
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
|
`CurrentSave` embedded in the room (no moderation state or asset arrays;
|
||||||
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
|
version fields are camelCase, alongside `unityAsset`/`unityAssetHash`). Keep the projections distinct.
|
||||||
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
|
- 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
|
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
|
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
|
`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
|
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
|
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
|
differs from the list row only in how it names bundles: dependency entries in `UnitySubAssets`
|
||||||
`{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle is lifted to top-level
|
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
|
`UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep
|
||||||
them straight.
|
them straight.
|
||||||
- The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are
|
- 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. */
|
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
|
||||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
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. */
|
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
|
||||||
export const KeepsakeConfig = z.object({
|
export const KeepsakeConfig = z.object({
|
||||||
KeepsakeFeatureEnabled: z.boolean(),
|
KeepsakeFeatureEnabled: z.boolean(),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
|
import { validateStudioBuildValidationToken } from '@repo/jwt'
|
||||||
|
|
||||||
import charadesAprilWords from '../../static/charades-april.json'
|
import charadesAprilWords from '../../static/charades-april.json'
|
||||||
import charadesWords from '../../static/charades.json'
|
import charadesWords from '../../static/charades.json'
|
||||||
import communityBoard from '../../static/community-board.json'
|
import communityBoard from '../../static/community-board.json'
|
||||||
@@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http'
|
|||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
BareString,
|
BareString,
|
||||||
|
BulkAllowedRequest,
|
||||||
|
BulkAllowedResponse,
|
||||||
|
form,
|
||||||
idParam,
|
idParam,
|
||||||
IsPureResponse,
|
IsPureResponse,
|
||||||
json,
|
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 -----------------------------------------------------------
|
// ---- Activities -----------------------------------------------------------
|
||||||
// Word bank for the Charades activity. The client requests the list by
|
// Word bank for the Charades activity. The client requests the list by
|
||||||
// activity name (`.../words/Charades`); other activities have no data yet.
|
// 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', () => {
|
describe('POST /api/sanitize/v1/isPure', () => {
|
||||||
const isPure = async (Value?: string, authed = true) =>
|
const isPure = async (Value?: string, authed = true) =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
|
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
|
||||||
@@ -10343,6 +10396,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/rooms/v1/verifyRole',
|
'POST /api/rooms/v1/verifyRole',
|
||||||
'POST /api/sanitize/v1',
|
'POST /api/sanitize/v1',
|
||||||
'POST /api/sanitize/v1/isPure',
|
'POST /api/sanitize/v1/isPure',
|
||||||
|
'POST /api/sanitize/v2/isAllowed/bulk',
|
||||||
'POST /api/v1/progression/bulk',
|
'POST /api/v1/progression/bulk',
|
||||||
'POST /outfits/bulk',
|
'POST /outfits/bulk',
|
||||||
'POST /statsigUserProperties',
|
'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. */
|
/** 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
|
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
|
* 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.
|
* 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 })
|
logger.info(`${role} role lookup`, { id })
|
||||||
const accountId = Number.parseInt(id, 10)
|
const accountId = Number.parseInt(id, 10)
|
||||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||||
@@ -1708,6 +1714,11 @@ const app = new Hono<App>()
|
|||||||
roleFilterAnswer(c, 'moderator')
|
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
|
// @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
|
// 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
|
// 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'),
|
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 {
|
return {
|
||||||
tags: ['Roles'],
|
tags: ['Roles'],
|
||||||
summary: `Whether a player has the ${role} role`,
|
summary: `Whether a player has the ${role} role`,
|
||||||
description:
|
description:
|
||||||
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
role === 'screenshare'
|
||||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
? '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.'
|
||||||
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
|
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||||
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
|
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||||
`different answer shape — which of several players hold the role, as an array.`,
|
`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: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'id',
|
name: 'id',
|
||||||
|
|||||||
@@ -1821,6 +1821,22 @@ describe('auth worker routes', () => {
|
|||||||
expect(await res.text()).toBe('')
|
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 () => {
|
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`)
|
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -2032,6 +2048,7 @@ describe('auth worker routes', () => {
|
|||||||
'GET /role/developer/{id}',
|
'GET /role/developer/{id}',
|
||||||
'GET /role/moderator',
|
'GET /role/moderator',
|
||||||
'GET /role/moderator/{id}',
|
'GET /role/moderator/{id}',
|
||||||
|
'GET /role/screenshare/{id}',
|
||||||
'POST /account/me/changepassword',
|
'POST /account/me/changepassword',
|
||||||
'POST /cachedlogin/forplatformid/{platform}/{id}',
|
'POST /cachedlogin/forplatformid/{platform}/{id}',
|
||||||
'POST /cachedlogin/forplatformids',
|
'POST /cachedlogin/forplatformids',
|
||||||
|
|||||||
+18
-4
@@ -353,6 +353,22 @@ const app = new Hono<App>()
|
|||||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
(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
|
// Rec Room Studio room bundles. The filename is the one a room save or
|
||||||
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
|
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
|
||||||
// `unity_asset.filename` records it. Only a recorded filename is served, so this
|
// `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') ?? ''
|
const name = c.req.param('filename') ?? ''
|
||||||
if (name.includes('..')) return c.body(null, 400)
|
if (name.includes('..')) return c.body(null, 400)
|
||||||
const key = await unityAssetR2Key(c.env.DB, name)
|
const key = await unityAssetR2Key(c.env.DB, name)
|
||||||
if (!key) return c.notFound()
|
return serveAsset(c, key ?? `unityasset/${name}`)
|
||||||
return serveAsset(c, key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||||
const key = await unityAssetR2Key(env.DB, name)
|
const key = await unityAssetR2Key(env.DB, name)
|
||||||
if (!key) return new Response(null, { status: 404 })
|
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
||||||
const head = await env.CDN_ASSETS.head(key)
|
|
||||||
if (!head) return new Response(null, { status: 404 })
|
if (!head) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ describe('cdn endpoints', () => {
|
|||||||
'GET /data/{id}',
|
'GET /data/{id}',
|
||||||
'GET /invention/{dataBlob}',
|
'GET /invention/{dataBlob}',
|
||||||
'GET /room/{dataBlob}',
|
'GET /room/{dataBlob}',
|
||||||
|
'GET /roomunityasset/{filename}',
|
||||||
'GET /sigs/{sigName}',
|
'GET /sigs/{sigName}',
|
||||||
'GET /unityasset/{filename}',
|
'GET /unityasset/{filename}',
|
||||||
'HEAD /unityasset/{filename}',
|
'HEAD /unityasset/{filename}',
|
||||||
@@ -377,3 +378,27 @@ describe('cdn endpoints', () => {
|
|||||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
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 {
|
interface SocketState {
|
||||||
connectionId: string
|
connectionId: string
|
||||||
handshakeDone: boolean
|
handshakeDone: boolean
|
||||||
|
channels?: string[]
|
||||||
/** The player this socket belongs to, from the token validated at connect. */
|
/** The player this socket belongs to, from the token validated at connect. */
|
||||||
playerId?: number
|
playerId?: number
|
||||||
}
|
}
|
||||||
@@ -356,6 +357,38 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
|
|
||||||
private handleInvocation(ws: WebSocket, connectionId: string, msg: HubMessage): void {
|
private handleInvocation(ws: WebSocket, connectionId: string, msg: HubMessage): void {
|
||||||
switch (msg.target) {
|
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': {
|
case 'SubscribeToPlayers': {
|
||||||
const playerIds = parsePlayerIds(msg.arguments)
|
const playerIds = parsePlayerIds(msg.arguments)
|
||||||
// An argument we can't read is not "subscribe to nobody": subscribing
|
// An argument we can't read is not "subscribe to nobody": subscribing
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const DEFAULT_CONNECT_PLAYER = 9000
|
|||||||
|
|
||||||
interface HubRecord {
|
interface HubRecord {
|
||||||
type?: number
|
type?: number
|
||||||
|
error?: string
|
||||||
target?: string
|
target?: string
|
||||||
invocationId?: string
|
invocationId?: string
|
||||||
result?: unknown
|
result?: unknown
|
||||||
@@ -197,6 +198,44 @@ describe('internal endpoint auth', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('hub protocol', () => {
|
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 () => {
|
test('GetSubscriptions reflects SubscribeToPlayers', async () => {
|
||||||
const { ws, waitFor } = await connect('conn-subs')
|
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.
|
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`
|
`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
|
(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
|
saves keep the arrays empty. `GET …/unityasset?unityAssetId=<guid>` returns the
|
||||||
those files; the bytes are on the CDN.
|
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
|
`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
|
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.
|
`{ UnityAssetId, Target, Version, Filename, Hash }`, one per asset that has a build.
|
||||||
|
|||||||
@@ -192,8 +192,11 @@ export const SubRoomDataSaveResponseDto = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.describe('Base64 SHA-256 of `unityAsset` when that file is set; otherwise null'),
|
.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`'),
|
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(),
|
savedByAccountId: z.int().nullable(),
|
||||||
savedOnPlatform: z
|
savedOnPlatform: z
|
||||||
.int()
|
.int()
|
||||||
@@ -225,11 +228,11 @@ export const UnitySubAssetDto = z.object({
|
|||||||
export const SubRoomDataSaveDto = z.object({
|
export const SubRoomDataSaveDto = z.object({
|
||||||
UnitySubAssets: z
|
UnitySubAssets: z
|
||||||
.array(UnitySubAssetDto)
|
.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'),
|
ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'),
|
||||||
SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'),
|
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'),
|
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'),
|
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'),
|
||||||
PersistenceVersion: z.int(),
|
PersistenceVersion: z.int(),
|
||||||
OMVersion: 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
|
* 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".
|
* 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. */
|
/** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */
|
||||||
export const MissingLookupParam = z
|
export const MissingLookupParam = z
|
||||||
@@ -588,6 +591,14 @@ export const RoomEnvelope = z.object({
|
|||||||
value: RoomDto.nullable(),
|
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
|
* 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
|
* 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.
|
* the fields actually posted are changed, and the names are matched case-insensitively.
|
||||||
*/
|
*/
|
||||||
export const RestrictionsRequest = z.object({
|
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`'),
|
supportsScreens: z.string().optional().describe('`True` / `False`'),
|
||||||
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
|
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
|
||||||
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
|
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
|
||||||
@@ -949,6 +961,8 @@ export const SaveSubRoomDataRequest = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('The save comment — a description of THIS revision, not the room’s description'),
|
.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'),
|
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'),
|
InventionUsage: z.string().optional().describe('Recorded on the subroom'),
|
||||||
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
|
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
|
||||||
AutoPublish: z
|
AutoPublish: z
|
||||||
@@ -982,7 +996,7 @@ export const SubRoomDataSaveNoUnityAssetsDto = z.object({
|
|||||||
SubRoomId: z.int(),
|
SubRoomId: z.int(),
|
||||||
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
|
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
|
||||||
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
|
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(),
|
DataBlobHash: z.string().nullable(),
|
||||||
PersistenceVersion: z.int(),
|
PersistenceVersion: z.int(),
|
||||||
OMVersion: z.int(),
|
OMVersion: z.int(),
|
||||||
@@ -1027,7 +1041,7 @@ export const SubRoomDataSaveDetailDto = z.object({
|
|||||||
SubRoomId: z.int(),
|
SubRoomId: z.int(),
|
||||||
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
|
UnityAssetId: z.string().nullable().describe('Null unless the save carried one'),
|
||||||
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'),
|
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(),
|
DataBlobHash: z.string().nullable(),
|
||||||
PersistenceVersion: z.int(),
|
PersistenceVersion: z.int(),
|
||||||
OMVersion: z.int(),
|
OMVersion: z.int(),
|
||||||
|
|||||||
+153
-47
@@ -16,6 +16,7 @@ import {
|
|||||||
cloneSubRoom,
|
cloneSubRoom,
|
||||||
countRoomsByCreator,
|
countRoomsByCreator,
|
||||||
createNotification,
|
createNotification,
|
||||||
|
createStudioRoom,
|
||||||
createSubRoom,
|
createSubRoom,
|
||||||
deleteExpiredRoomInvites,
|
deleteExpiredRoomInvites,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
@@ -46,7 +47,6 @@ import {
|
|||||||
getRoomsByIds,
|
getRoomsByIds,
|
||||||
getSimilarRooms,
|
getSimilarRooms,
|
||||||
getStudioUnityAsset,
|
getStudioUnityAsset,
|
||||||
getSubRoom,
|
|
||||||
getSubRoomPermissions,
|
getSubRoomPermissions,
|
||||||
getSubRoomSaveById,
|
getSubRoomSaveById,
|
||||||
getSubRoomSaves,
|
getSubRoomSaves,
|
||||||
@@ -159,6 +159,7 @@ import {
|
|||||||
roomIdParam,
|
roomIdParam,
|
||||||
RoomLookup,
|
RoomLookup,
|
||||||
RoomPascalEnvelope,
|
RoomPascalEnvelope,
|
||||||
|
RoomRestrictionsEnvelope,
|
||||||
RoomResultEnvelope,
|
RoomResultEnvelope,
|
||||||
RoomRoleDto,
|
RoomRoleDto,
|
||||||
RoomSaveEnvelope,
|
RoomSaveEnvelope,
|
||||||
@@ -933,11 +934,15 @@ function toSaveResponse(save: Record<string, unknown>, target: number | null = n
|
|||||||
unityAssetId: str(save.UnityAssetId),
|
unityAssetId: str(save.UnityAssetId),
|
||||||
unityAsset: baked?.filename ?? null,
|
unityAsset: baked?.filename ?? null,
|
||||||
unityAssetHash: baked?.hash ?? null,
|
unityAssetHash: baked?.hash ?? null,
|
||||||
dataBlob: str(save.DataBlob) ?? '',
|
dataBlob: str(save.DataBlob),
|
||||||
dataBlobHash: str(save.DataBlobHash),
|
dataBlobHash: str(save.DataBlobHash),
|
||||||
|
persistenceVersion: num(save.PersistenceVersion) ?? 0,
|
||||||
|
omVersion: num(save.OMVersion) ?? 0,
|
||||||
|
ugcSubVersion: num(save.UgcSubVersion) ?? 0,
|
||||||
savedByAccountId: num(save.SavedByAccountId),
|
savedByAccountId: num(save.SavedByAccountId),
|
||||||
savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
|
savedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0),
|
||||||
savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
|
savedOnDeviceClass:
|
||||||
|
save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0),
|
||||||
description: str(save.Description),
|
description: str(save.Description),
|
||||||
createdAt: str(save.CreatedAt) ?? '',
|
createdAt: str(save.CreatedAt) ?? '',
|
||||||
}
|
}
|
||||||
@@ -1014,13 +1019,14 @@ function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
|
|||||||
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
|
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
|
||||||
? save.ReferencedUnityAssetIds
|
? save.ReferencedUnityAssetIds
|
||||||
: [],
|
: [],
|
||||||
DataBlob: str(save.DataBlob) ?? '',
|
DataBlob: str(save.DataBlob),
|
||||||
DataBlobHash: str(save.DataBlobHash),
|
DataBlobHash: str(save.DataBlobHash),
|
||||||
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
|
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
|
||||||
OMVersion: num(save.OMVersion) ?? 0,
|
OMVersion: num(save.OMVersion) ?? 0,
|
||||||
SavedByAccountId: num(save.SavedByAccountId),
|
SavedByAccountId: num(save.SavedByAccountId),
|
||||||
SavedOnPlatform: num(save.SavedOnPlatform) ?? 0,
|
SavedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0),
|
||||||
SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
|
SavedOnDeviceClass:
|
||||||
|
save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0),
|
||||||
Description: str(save.Description) ?? '',
|
Description: str(save.Description) ?? '',
|
||||||
ModerationState: num(save.ModerationState) ?? 0,
|
ModerationState: num(save.ModerationState) ?? 0,
|
||||||
CreatedAt: str(save.CreatedAt) ?? '',
|
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
|
* 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
|
* `…/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
|
* values were all missing). Two things differ from the list row, both observed against
|
||||||
* the reference:
|
* the reference:
|
||||||
*
|
*
|
||||||
* - Each `UnitySubAssets` entry is `{ UnityAssetId, UnityAsset, UnityAssetHash }` — the
|
* - The save's main bundle is at top-level `UnityAsset`/`UnityAssetHash`. Only other
|
||||||
* stored `Filename`/`Hash` under the names the client's save-detail decoder reads —
|
* bundles belong in `UnitySubAssets`, as `{ UnityAssetId, UnityAsset, UnityAssetHash }`.
|
||||||
* and the save's own main bundle is lifted to top-level `UnityAsset`/`UnityAssetHash`
|
* Concrete filenames use the named target, Windows when absent; the main fields
|
||||||
* (the named target's, Windows when none was named; both null for a maker-pen save).
|
* are null for a maker-pen save.
|
||||||
* - No `Tags`, and `UnityAssetId` is always present (null when the save carried none).
|
* - No `Tags`, and `UnityAssetId` is always present (null when the save carried none).
|
||||||
*
|
*
|
||||||
* Built key by key like {@link toSaveWithoutUnityAssets}, for the same reason.
|
* 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 str = (v: unknown) => (typeof v === 'string' ? v : null)
|
||||||
const num = (v: unknown) => (typeof v === 'number' ? v : null)
|
const num = (v: unknown) => (typeof v === 'number' ? v : null)
|
||||||
const main = pickBakedBundle(save, target)
|
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(
|
const bundles = (Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []).filter(
|
||||||
(item): item is Record<string, unknown> =>
|
(item): item is Record<string, unknown> =>
|
||||||
typeof item === 'object' &&
|
typeof item === 'object' &&
|
||||||
item !== null &&
|
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 {
|
return {
|
||||||
UnitySubAssets: bundles.map((row) => ({
|
UnitySubAssets: bundles.map((row) => ({
|
||||||
@@ -1070,7 +1094,7 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
|
|||||||
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
|
ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
|
||||||
? save.ReferencedUnityAssetIds
|
? save.ReferencedUnityAssetIds
|
||||||
: [],
|
: [],
|
||||||
DataBlob: str(save.DataBlob) ?? '',
|
DataBlob: str(save.DataBlob),
|
||||||
DataBlobHash: str(save.DataBlobHash),
|
DataBlobHash: str(save.DataBlobHash),
|
||||||
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
|
PersistenceVersion: num(save.PersistenceVersion) ?? 0,
|
||||||
OMVersion: num(save.OMVersion) ?? 0,
|
OMVersion: num(save.OMVersion) ?? 0,
|
||||||
@@ -1121,7 +1145,9 @@ async function ownedRooms(c: Context<App>) {
|
|||||||
async function ownedRoomsExcludingDorm(c: Context<App>) {
|
async function ownedRoomsExcludingDorm(c: Context<App>) {
|
||||||
const accountId = await authedAccountId(c)
|
const accountId = await authedAccountId(c)
|
||||||
if (accountId === null) return unauthorized(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))
|
return c.json(rooms.filter((r) => r.IsDorm !== true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,10 +1304,42 @@ const app = new Hono<App>()
|
|||||||
if (idParam) {
|
if (idParam) {
|
||||||
const id = firstId(idParam)
|
const id = firstId(idParam)
|
||||||
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
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 ?? '')
|
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',
|
'/rooms/ownedby/me',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['My rooms'],
|
tags: ['My rooms'],
|
||||||
summary: 'Rooms the caller owns (excluding their dorm)',
|
summary: 'Rooms the caller owns or co-owns (excluding their dorm)',
|
||||||
description: [
|
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',
|
'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',
|
'`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',
|
'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
|
// 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
|
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
|
||||||
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
||||||
.put(
|
.on(
|
||||||
'/rooms/:roomId{[0-9]+}/restrictions',
|
'PUT',
|
||||||
|
['/rooms/:roomId{[0-9]+}/restrictions', '/rooms/:roomId{[0-9]+}/v2/restrictions'],
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room settings'],
|
tags: ['Room settings'],
|
||||||
summary: 'Set a room’s platform/movement support flags',
|
summary: 'Set a room’s platform/movement support flags',
|
||||||
@@ -3567,7 +3626,10 @@ const app = new Hono<App>()
|
|||||||
parameters: [roomIdParam],
|
parameters: [roomIdParam],
|
||||||
requestBody: form(RestrictionsRequest, 'The flags to change'),
|
requestBody: form(RestrictionsRequest, 'The flags to change'),
|
||||||
responses: {
|
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,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: FORBIDDEN_RESPONSE,
|
403: FORBIDDEN_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -3584,7 +3646,7 @@ const app = new Hono<App>()
|
|||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
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)) {
|
for (const [key, value] of Object.entries(body)) {
|
||||||
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
||||||
if (field !== undefined && typeof value === 'string') {
|
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)
|
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
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)
|
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
|
// `TotalResults` counts the whole history, not the page — that is what the client
|
||||||
// pages against.
|
// pages against.
|
||||||
return c.json({
|
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,
|
TotalResults: saves.length,
|
||||||
TotalCount: 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
|
// The authoring manifest on a save. Its JSON source filename is independent of
|
||||||
// this after a build; the game reads the same bundles from `CurrentSave` and then
|
// the baked game bundles; a baked-only archive has no editable source to return.
|
||||||
// downloads `filename` from the CDN. Bare object, no envelope, no auth — the room
|
|
||||||
// document already names the files.
|
|
||||||
.get(
|
.get(
|
||||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset',
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Subrooms'],
|
tags: ['Subrooms'],
|
||||||
summary: 'The unity asset attached to a subroom save',
|
summary: 'The unity asset attached to a subroom save',
|
||||||
description: [
|
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`,',
|
'on a save of this subroom. A bare object: `unityAssetId`, `createdByAccountId`,',
|
||||||
'`bakedUnityAssets` (one entry per main bundle), plus `filename` and `hash` for the',
|
'`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',
|
'Stripped bundles are not listed. The published current save is public. Other revisions require the same',
|
||||||
'not listed. An unknown room, subroom, or asset is a 404. No auth: `GET /rooms/{id}`',
|
'authentication and permissions as save history. Without an asset ID, authorized',
|
||||||
'already includes the same filenames on `CurrentSave`.',
|
'readers receive the latest source; other callers receive only the published source.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
roomIdParam,
|
roomIdParam,
|
||||||
@@ -4029,6 +4108,8 @@ const app = new Hono<App>()
|
|||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(UnityAssetWithSourceDto, 'The unity asset and its baked bundles'),
|
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' },
|
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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
const unityAssetId = c.req.query('unityAssetId') ?? ''
|
const unityAssetId = c.req.query('unityAssetId') ?? ''
|
||||||
if (unityAssetId === '') return c.notFound()
|
|
||||||
|
|
||||||
const sub = await getSubRoom(c.env.DB, roomId, subRoomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!sub) return c.notFound()
|
const sub = room && findSubRoom(room, subRoomId)
|
||||||
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
|
if (!room || !sub) return c.notFound()
|
||||||
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
|
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()
|
if (!save) return c.notFound()
|
||||||
|
const source = studioSource(save)
|
||||||
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]
|
|
||||||
if (!source) return c.notFound()
|
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
|
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
|
||||||
return c.json({
|
return c.json({
|
||||||
unityAssetId: asset.unityAssetId,
|
unityAssetId: String(save.UnityAssetId),
|
||||||
createdByAccountId: asset.createdByAccountId || savedBy,
|
createdByAccountId: asset?.createdByAccountId || savedBy,
|
||||||
bakedUnityAssets: mains.map((build) => ({
|
bakedUnityAssets: mains.map((build) => ({
|
||||||
unityAssetId: build.UnityAssetId,
|
unityAssetId: build.UnityAssetId,
|
||||||
target: build.Target,
|
target: build.Target,
|
||||||
@@ -4132,8 +4226,11 @@ const app = new Hono<App>()
|
|||||||
RoomData?: { Filename?: string; Hash?: string | null }
|
RoomData?: { Filename?: string; Hash?: string | null }
|
||||||
SubRoomData?: { Filename?: string; Hash?: string | null }
|
SubRoomData?: { Filename?: string; Hash?: string | null }
|
||||||
UnityAssetId?: string | null
|
UnityAssetId?: string | null
|
||||||
|
UnityAsset?: { Filename?: string; Hash?: string | null }
|
||||||
Description?: string
|
Description?: string
|
||||||
PersistenceVersion?: number
|
PersistenceVersion?: number
|
||||||
|
OMVersion?: number
|
||||||
|
UgcSubVersion?: number
|
||||||
InventionUsage?: string
|
InventionUsage?: string
|
||||||
AutoPublish?: boolean
|
AutoPublish?: boolean
|
||||||
}
|
}
|
||||||
@@ -4145,10 +4242,19 @@ const app = new Hono<App>()
|
|||||||
roomDataFilename: body.RoomData?.Filename,
|
roomDataFilename: body.RoomData?.Filename,
|
||||||
roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined,
|
roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined,
|
||||||
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : 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,
|
autoPublish: body.AutoPublish === true,
|
||||||
description: typeof body.Description === 'string' ? body.Description : undefined,
|
description: typeof body.Description === 'string' ? body.Description : undefined,
|
||||||
persistenceVersion:
|
persistenceVersion:
|
||||||
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
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,
|
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
|
||||||
})
|
})
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@@ -3744,6 +3744,9 @@ describe('rooms endpoints', () => {
|
|||||||
|
|
||||||
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
|
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
|
||||||
expect(saved.value.subRoomDataSave).toEqual({
|
expect(saved.value.subRoomDataSave).toEqual({
|
||||||
|
persistenceVersion: 41,
|
||||||
|
omVersion: 0,
|
||||||
|
ugcSubVersion: 0,
|
||||||
subRoomDataSaveId: expect.any(Number),
|
subRoomDataSaveId: expect.any(Number),
|
||||||
subRoomId: 2,
|
subRoomId: 2,
|
||||||
unityAssetId: null,
|
unityAssetId: null,
|
||||||
@@ -6076,6 +6079,7 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/{roomId}/bans',
|
'GET /rooms/{roomId}/bans',
|
||||||
'GET /rooms/{roomId}/bans/history',
|
'GET /rooms/{roomId}/bans/history',
|
||||||
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
|
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
|
||||||
|
'GET /rooms/{roomId}/boosts/{playerId}/count',
|
||||||
'GET /rooms/{roomId}/experience',
|
'GET /rooms/{roomId}/experience',
|
||||||
'GET /rooms/{roomId}/experience/player',
|
'GET /rooms/{roomId}/experience/player',
|
||||||
'GET /rooms/{roomId}/interactionby/me',
|
'GET /rooms/{roomId}/interactionby/me',
|
||||||
@@ -6089,6 +6093,7 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /roomserver/rooms/createdby/me',
|
'GET /roomserver/rooms/createdby/me',
|
||||||
'GET /showcase/{playerId}',
|
'GET /showcase/{playerId}',
|
||||||
'POST /rooms/bulk',
|
'POST /rooms/bulk',
|
||||||
|
'POST /rooms/create',
|
||||||
'POST /rooms/{roomId}/bans',
|
'POST /rooms/{roomId}/bans',
|
||||||
'POST /rooms/{roomId}/clone',
|
'POST /rooms/{roomId}/clone',
|
||||||
'POST /rooms/{roomId}/experience',
|
'POST /rooms/{roomId}/experience',
|
||||||
@@ -6119,6 +6124,7 @@ describe('rooms endpoints', () => {
|
|||||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
||||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
|
||||||
'PUT /rooms/{roomId}/tags',
|
'PUT /rooms/{roomId}/tags',
|
||||||
|
'PUT /rooms/{roomId}/v2/restrictions',
|
||||||
'PUT /rooms/{roomId}/warning',
|
'PUT /rooms/{roomId}/warning',
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -6135,7 +6141,7 @@ describe('rooms endpoints', () => {
|
|||||||
// `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
|
// `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
|
||||||
// table must not fail the room read.
|
// table must not fail the room read.
|
||||||
describe('studio room bundles', () => {
|
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 winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main')
|
||||||
const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
|
const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
|
||||||
const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
||||||
@@ -6157,7 +6163,12 @@ describe('studio room bundles', () => {
|
|||||||
Tags: [],
|
Tags: [],
|
||||||
ModerationState: 0,
|
ModerationState: 0,
|
||||||
CreatedAt: '2026-10-03T00:00:00.000Z',
|
CreatedAt: '2026-10-03T00:00:00.000Z',
|
||||||
...(unityAssetId ? { UnityAssetId: unityAssetId } : {}),
|
...(unityAssetId
|
||||||
|
? {
|
||||||
|
UnityAssetId: unityAssetId,
|
||||||
|
StudioSourceAsset: { Filename: 'source-manifest.json', Hash: 'source-hash' },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
@@ -6294,20 +6305,24 @@ describe('studio room bundles', () => {
|
|||||||
{ unityAssetId: ASSET, target: 0, version: 1, filename: winName },
|
{ unityAssetId: ASSET, target: 0, version: 1, filename: winName },
|
||||||
{ unityAssetId: ASSET, target: 2, version: 1, filename: androidName },
|
{ unityAssetId: ASSET, target: 2, version: 1, filename: androidName },
|
||||||
],
|
],
|
||||||
filename: winName,
|
filename: 'source-manifest.json',
|
||||||
hash: winHash,
|
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(
|
expect(
|
||||||
(
|
(
|
||||||
await SELF.fetch(
|
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
|
).status
|
||||||
).toBe(404)
|
).toBe(404)
|
||||||
expect(
|
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)
|
).toBe(404)
|
||||||
|
|
||||||
const detail = await SELF.fetch(
|
const detail = await SELF.fetch(
|
||||||
@@ -6316,15 +6331,12 @@ describe('studio room bundles', () => {
|
|||||||
)
|
)
|
||||||
expect(detail.status).toBe(200)
|
expect(detail.status).toBe(200)
|
||||||
// The detail lifts the Windows bundle to the top level when no target is named, and
|
// 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
|
// lists only dependencies in UnitySubAssets. Repeating the root there makes the
|
||||||
// `Filename`/`Hash`.
|
// game load it twice and Unity rejects the second load with room error 8714.
|
||||||
expect(await detail.json()).toMatchObject({
|
expect(await detail.json()).toMatchObject({
|
||||||
UnityAsset: winName,
|
UnityAsset: winName,
|
||||||
UnityAssetHash: winHash,
|
UnityAssetHash: winHash,
|
||||||
UnitySubAssets: [
|
UnitySubAssets: [],
|
||||||
{ UnityAssetId: ASSET, UnityAsset: winName, UnityAssetHash: winHash },
|
|
||||||
{ UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const questDetail = await SELF.fetch(
|
const questDetail = await SELF.fetch(
|
||||||
@@ -6334,9 +6346,7 @@ describe('studio room bundles', () => {
|
|||||||
expect(await questDetail.json()).toMatchObject({
|
expect(await questDetail.json()).toMatchObject({
|
||||||
UnityAsset: androidName,
|
UnityAsset: androidName,
|
||||||
UnityAssetHash: androidHash,
|
UnityAssetHash: androidHash,
|
||||||
UnitySubAssets: [
|
UnitySubAssets: [],
|
||||||
{ UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const light = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/no_unity_assets`, {
|
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
|
// 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
|
// 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
|
// 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([])
|
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
|
```sh
|
||||||
pnpm turbo deploy
|
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',
|
4: 'video',
|
||||||
5: 'invention',
|
5: 'invention',
|
||||||
6: 'room',
|
6: 'room',
|
||||||
|
8: 'room',
|
||||||
|
9: 'roomunityasset',
|
||||||
|
10: 'unityasset',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
/** 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
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -140,6 +258,46 @@ const app = new Hono<App>()
|
|||||||
// (the `<upload-date>/<random-name>` part the `cdn` worker serves back) the client
|
// (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
|
// references it by. Also accepts a name-only post (no binary) that just echoes
|
||||||
// back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`.
|
// 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(
|
.post(
|
||||||
'/upload',
|
'/upload',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -173,16 +331,18 @@ const app = new Hono<App>()
|
|||||||
const id = await validateAndGetAccountId(c.req.raw, secret)
|
const id = await validateAndGetAccountId(c.req.raw, secret)
|
||||||
if (id === null) return c.body(null, 401)
|
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),
|
// The binary part is identified by being a file (filename/content-type),
|
||||||
// not by its field name — matching the reference's part detection.
|
// not by its field name — matching the reference's part detection.
|
||||||
const file = Object.values(body).find((v): v is File => v instanceof File)
|
const file = Object.values(body).find((v): v is File => v instanceof File)
|
||||||
|
|
||||||
if (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) {
|
if (!isStaff && file.size > limit) {
|
||||||
return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413)
|
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.
|
// worker answer one from a HEAD instead of downloading the blob to digest it.
|
||||||
sha256: await crypto.subtle.digest('SHA-256', bytes),
|
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 })
|
return c.json({ filename })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ it('GET /openapi.json documents every route', async () => {
|
|||||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
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
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
// documentation.
|
// 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).
|
// Every schema inlines: a `$ref` here would be a dangling reference (see openapi.ts).
|
||||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
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>`).
|
The RecFlare Studio host (`https://studio.<domain>`).
|
||||||
|
|
||||||
Device login stays on Auth, `GET /account/me` stays on Accounts, and the approval
|
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
|
page is WWW `/device`. This worker queues cloud builds, accepts completed bundles,
|
||||||
locally built Windows and Android bundle pair, and answers the co-owner
|
and serves build status and co-owner presence. Rooms exposes playable bundles in
|
||||||
presence list. It does not serve the bundle bytes. A stored build is loaded from
|
`CurrentSave.UnitySubAssets`; their bytes are served by the CDN at `/room/{filename}`.
|
||||||
Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`)
|
The Rooms `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset` route separately
|
||||||
and from the bucket at `/room/{filename}`.
|
returns the editable source manifest and available build metadata.
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| 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/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. |
|
| `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. |
|
| `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. |
|
| `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
|
## Development
|
||||||
|
|
||||||
@@ -49,3 +49,36 @@ the rooms migrations too. Deploying does not apply either.
|
|||||||
just migrate -F studio # remote
|
just migrate -F studio # remote
|
||||||
just migrate -F studio -- --local # dev db
|
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
|
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
||||||
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
||||||
CDN_ASSETS: R2Bucket
|
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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface CloudBuildRow {
|
|||||||
error: string | null
|
error: string | null
|
||||||
unityAssetId: string
|
unityAssetId: string
|
||||||
createdByAccountId: number
|
createdByAccountId: number
|
||||||
|
serverDataFileName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BundlePlatform = StudioBundlePlatform
|
export type BundlePlatform = StudioBundlePlatform
|
||||||
@@ -58,10 +59,14 @@ function toCloudBuild(row: BuildListRow): CloudBuildRow {
|
|||||||
return {
|
return {
|
||||||
cloudBuildId: row.cloud_build_id,
|
cloudBuildId: row.cloud_build_id,
|
||||||
startedAt: row.started_at,
|
startedAt: row.started_at,
|
||||||
completedAt: row.completed_at,
|
completedAt: row.completed_at || null,
|
||||||
error: row.error,
|
error: row.error,
|
||||||
unityAssetId: row.unity_asset_id,
|
unityAssetId: row.unity_asset_id,
|
||||||
createdByAccountId: row.created_by_account_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 { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
|
import cloudBuilds from './cloud-builds'
|
||||||
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||||
import { parseMultipart } from './multipart'
|
import { parseMultipart } from './multipart'
|
||||||
|
|
||||||
@@ -160,10 +161,14 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
.route('/', cloudBuilds)
|
||||||
|
|
||||||
.get('/', async (c) => {
|
.get('/', async (c) => {
|
||||||
return c.text('hello, world!')
|
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
|
// The editor lists a room's cloud builds with
|
||||||
// GET /cloud-builds/for-room?roomId=&subRoomId=&skip=&take= on this host.
|
// 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.
|
// Local Windows + Android bundles from the editor button under Upload.
|
||||||
.post('/cloud-builds/from-editor', (c) => postLocalCloudBuild(c))
|
.post('/cloud-builds/from-editor', (c) => postLocalCloudBuild(c))
|
||||||
|
|
||||||
// Studio asks how often it may request cloud builds. Stubbed: this server
|
// CloudBuildRateLimitConfigDTO reads these three fields directly (no Result
|
||||||
// never queues a real cloud build (every build is local, see from-editor),
|
// envelope). Missing MaxBuildsPerInterval becomes zero and blocks the first build.
|
||||||
// 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.
|
|
||||||
.get('/cloud-builds/get-rate-limit-config', (c) =>
|
.get('/cloud-builds/get-rate-limit-config', (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
maxBuildsPerHour: 100,
|
buildThrottleIntervalHours: 1,
|
||||||
maxConcurrentBuilds: 10,
|
warnAfterBuildsPerInterval: 90,
|
||||||
cooldownSeconds: 0,
|
maxBuildsPerInterval: 100,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ declare module 'cloudflare:test' {
|
|||||||
const ORIGIN = 'https://example.com'
|
const ORIGIN = 'https://example.com'
|
||||||
const TEST_SECRET = 'test-signing-key'
|
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 {
|
function b64url(input: ArrayBuffer | string): string {
|
||||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||||
let binary = ''
|
let binary = ''
|
||||||
@@ -113,13 +119,13 @@ it('lists no cloud builds for a room', async () => {
|
|||||||
expect(await res.json()).toEqual({ results: [], totalResults: 0 })
|
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')
|
const res = await SELF.fetch('https://example.com/cloud-builds/get-rate-limit-config')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
maxBuildsPerHour: 100,
|
buildThrottleIntervalHours: 1,
|
||||||
maxConcurrentBuilds: 10,
|
warnAfterBuildsPerInterval: 90,
|
||||||
cooldownSeconds: 0,
|
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
|
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). */
|
/** Set a room's Description in place (the caller is responsible for the owner check). */
|
||||||
export async function setRoomDescription(
|
export async function setRoomDescription(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -1439,9 +1527,13 @@ export interface SaveSubRoomDataInput {
|
|||||||
*/
|
*/
|
||||||
description?: string
|
description?: string
|
||||||
persistenceVersion?: number
|
persistenceVersion?: number
|
||||||
|
omVersion?: number
|
||||||
|
ugcSubVersion?: number
|
||||||
inventionUsage?: string
|
inventionUsage?: string
|
||||||
/** Optional baked-asset id; emitted on the save only when present. */
|
/** Optional baked-asset id; emitted on the save only when present. */
|
||||||
unityAssetId?: string
|
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
|
* 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
|
* 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. */
|
/** Fields that vary between a real save and one reconstructed from the legacy shape. */
|
||||||
interface BuildSaveInput {
|
interface BuildSaveInput {
|
||||||
subRoomId: unknown
|
subRoomId: unknown
|
||||||
dataBlob: string
|
dataBlob: string | null
|
||||||
dataBlobHash: string | null
|
dataBlobHash: string | null
|
||||||
persistenceVersion: number
|
persistenceVersion: number
|
||||||
|
omVersion?: number
|
||||||
|
ugcSubVersion?: number
|
||||||
savedByAccountId: unknown
|
savedByAccountId: unknown
|
||||||
description: string
|
description: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -1511,8 +1605,8 @@ function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave {
|
|||||||
DataBlobHash: input.dataBlobHash,
|
DataBlobHash: input.dataBlobHash,
|
||||||
ReferencedUnityAssetIds: [],
|
ReferencedUnityAssetIds: [],
|
||||||
PersistenceVersion: input.persistenceVersion,
|
PersistenceVersion: input.persistenceVersion,
|
||||||
OMVersion: 0,
|
OMVersion: input.omVersion ?? 0,
|
||||||
UgcSubVersion: 0,
|
UgcSubVersion: input.ugcSubVersion ?? 0,
|
||||||
SavedByAccountId: input.savedByAccountId,
|
SavedByAccountId: input.savedByAccountId,
|
||||||
SavedOnPlatform: 0,
|
SavedOnPlatform: 0,
|
||||||
SavedOnDeviceClass: 0,
|
SavedOnDeviceClass: 0,
|
||||||
@@ -1745,25 +1839,34 @@ export async function saveSubRoomData(
|
|||||||
: undefined)
|
: undefined)
|
||||||
const priorVersion = previous?.PersistenceVersion
|
const priorVersion = previous?.PersistenceVersion
|
||||||
const priorBlob = previous?.DataBlob
|
const priorBlob = previous?.DataBlob
|
||||||
const save = await insertSubRoomSave(
|
const saveData = buildSubRoomSave({
|
||||||
db,
|
|
||||||
subRoomId,
|
subRoomId,
|
||||||
buildSubRoomSave({
|
// A save that carries no new blob (e.g. a description-only save) keeps the one
|
||||||
subRoomId,
|
// the subroom already loads from.
|
||||||
// A save that carries no new blob (e.g. a description-only save) keeps the one
|
dataBlob:
|
||||||
// the subroom already loads from.
|
input.subRoomDataFilename ??
|
||||||
dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''),
|
(typeof priorBlob === 'string' || priorBlob === null ? priorBlob : ''),
|
||||||
dataBlobHash: input.subRoomDataHash ?? null,
|
dataBlobHash: input.subRoomDataHash ?? null,
|
||||||
persistenceVersion:
|
persistenceVersion:
|
||||||
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
|
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
|
||||||
savedByAccountId: accountId,
|
omVersion:
|
||||||
// The save comment — empty string, not null, when the save carries none (the
|
input.omVersion ?? (typeof previous?.OMVersion === 'number' ? previous.OMVersion : 0),
|
||||||
// reference's `roomDesc ?? ""`).
|
ugcSubVersion:
|
||||||
description: input.description ?? '',
|
input.ugcSubVersion ??
|
||||||
createdAt: new Date().toISOString(),
|
(typeof previous?.UgcSubVersion === 'number' ? previous.UgcSubVersion : 0),
|
||||||
unityAssetId: input.unityAssetId,
|
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)
|
const saveId = Number(save.SubRoomDataSaveId)
|
||||||
sub.DataSavedAt = new Date().toISOString()
|
sub.DataSavedAt = new Date().toISOString()
|
||||||
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
|
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
|
||||||
@@ -1951,7 +2054,8 @@ export async function createSubRoom(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
roomId: number,
|
roomId: number,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
name: string
|
name: string,
|
||||||
|
initialScene?: string
|
||||||
): Promise<{ room: Room; subRoom: SubRoom } | null> {
|
): Promise<{ room: Room; subRoom: SubRoom } | null> {
|
||||||
const room = await getRoomById(db, roomId)
|
const room = await getRoomById(db, roomId)
|
||||||
if (!room) return null
|
if (!room) return null
|
||||||
@@ -1959,14 +2063,29 @@ export async function createSubRoom(
|
|||||||
const subRoom = await insertSubRoom(db, roomId, {
|
const subRoom = await insertSubRoom(db, roomId, {
|
||||||
Name: name,
|
Name: name,
|
||||||
CreatorAccountId: accountId,
|
CreatorAccountId: accountId,
|
||||||
UnitySceneId: await baseSubRoomScene(db, roomId),
|
UnitySceneId: initialScene ?? (await baseSubRoomScene(db, roomId)),
|
||||||
MaxPlayers: 4,
|
MaxPlayers: 4,
|
||||||
Accessibility: Accessibility.Unlisted,
|
Accessibility: Accessibility.Unlisted,
|
||||||
IsSandbox: true,
|
IsSandbox: true,
|
||||||
LastModeratedSaveModerationState: 0,
|
LastModeratedSaveModerationState: 0,
|
||||||
ShouldAutoStageSaves: true,
|
ShouldAutoStageSaves: true,
|
||||||
// Nothing saved yet — the first room save mints one and points current_save_id
|
// Rooms 2 validates CurrentSave.OMVersion even when the scene uses its built-in
|
||||||
// at it. Until then the subroom reads with `CurrentSave: null`.
|
// 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.
|
// Refresh the hydrated SubRooms so the returned room includes the one just inserted.
|
||||||
await attachSubRooms(db, [room])
|
await attachSubRooms(db, [room])
|
||||||
@@ -2706,6 +2825,20 @@ async function hydrateRooms(
|
|||||||
rooms: Room[],
|
rooms: Room[],
|
||||||
stats?: Map<number, RoomStats>
|
stats?: Map<number, RoomStats>
|
||||||
): Promise<Room[]> {
|
): 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([
|
await Promise.all([
|
||||||
attachSubRooms(db, rooms),
|
attachSubRooms(db, rooms),
|
||||||
attachTags(db, rooms),
|
attachTags(db, rooms),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export {
|
|||||||
validateAndGetRoles,
|
validateAndGetRoles,
|
||||||
validateAndGetVersion,
|
validateAndGetVersion,
|
||||||
generateToken,
|
generateToken,
|
||||||
|
generateStudioBuildValidationToken,
|
||||||
|
validateStudioBuildValidationToken,
|
||||||
generatePhotonAuthToken,
|
generatePhotonAuthToken,
|
||||||
TOKEN_TTL_SECONDS,
|
TOKEN_TTL_SECONDS,
|
||||||
} from './jwt'
|
} 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.
|
// last a day so the client doesn't have to refresh it.
|
||||||
export const TOKEN_TTL_SECONDS = 86400
|
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
|
* 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.
|
* the token is malformed, has a bad signature, or is expired/not-yet-valid.
|
||||||
|
|||||||
Reference in New Issue
Block a user