From a3291bc9ce50dad4aa2e2eb0c16f88f808a0372b Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Mon, 19 Oct 2026 11:04:58 -0400 Subject: [PATCH] [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 --- CLAUDE.md | 8 +- apps/api/src/openapi.ts | 8 + apps/api/src/routes/gameplay.ts | 41 ++ apps/api/src/test/integration/api.test.ts | 54 ++ .../studio-build-validation.test.ts | 52 ++ apps/auth/src/auth.app.ts | 15 +- apps/auth/src/openapi.ts | 14 +- apps/auth/src/test/integration/api.test.ts | 17 + apps/cdn/src/cdn.app.ts | 22 +- apps/cdn/src/test/integration/api.test.ts | 25 + apps/notify/src/notifications-hub.ts | 33 ++ apps/notify/src/test/integration/api.test.ts | 39 ++ apps/rooms/README.md | 8 +- apps/rooms/src/openapi.ts | 26 +- apps/rooms/src/rooms.app.ts | 200 +++++-- apps/rooms/src/test/integration/api.test.ts | 448 ++++++++++++++- apps/storage/README.md | 16 + apps/storage/src/storage.app.ts | 175 +++++- apps/storage/src/test/integration/api.test.ts | 166 +++++- apps/studio/README.md | 55 +- .../0003_studio_cloud_build_job.sql | 10 + apps/studio/src/cloud-builds.ts | 528 ++++++++++++++++++ apps/studio/src/context.ts | 4 + apps/studio/src/local-builds.ts | 7 +- apps/studio/src/studio.app.ts | 18 +- apps/studio/src/test/integration/api.test.ts | 14 +- .../src/test/integration/cloud-builds.test.ts | 470 ++++++++++++++++ packages/domain/src/rooms-db.ts | 183 +++++- packages/jwt/src/index.ts | 2 + packages/jwt/src/jwt.ts | 37 ++ 30 files changed, 2553 insertions(+), 142 deletions(-) create mode 100644 apps/api/src/test/integration/studio-build-validation.test.ts create mode 100644 apps/studio/migrations/0003_studio_cloud_build_job.sql create mode 100644 apps/studio/src/cloud-builds.ts create mode 100644 apps/studio/src/test/integration/cloud-builds.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0ed86ee..7c6fd86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,8 +105,8 @@ inconsistency here without checking the client first. - The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape: `value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The `subRoomDataSave` is camelCase with a different field set from the PascalCase - `CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state, - no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections. + `CurrentSave` embedded in the room (no moderation state or asset arrays; + version fields are camelCase, alongside `unityAsset`/`unityAssetHash`). Keep the projections distinct. - A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`), NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads nothing. The key must be present (null before the first publish); read it via @@ -123,8 +123,8 @@ inconsistency here without checking the client first. `GET …/saves/:saveId` is the detail behind a list row, under the same gate, and a PASCALCASE row like the list serves — NOT the camelCase projection the room save's response uses (served that, the client showed a save with every value missing). It - differs from the list row only in how it names bundles: each `UnitySubAssets` entry is - `{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle is lifted to top-level + differs from the list row only in how it names bundles: dependency entries in `UnitySubAssets` + are `{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle appears only at top-level `UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep them straight. - The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index bf5b25c..cd26c44 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1111,6 +1111,14 @@ export const SanitizeRequest = z.object({ /** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */ export const IsPureResponse = z.object({ IsPure: z.boolean() }) +/** Studio sends repeated Values form fields and consumes this result envelope. */ +export const BulkAllowedRequest = z.object({ Values: z.array(z.string()) }) +export const BulkAllowedResponse = z.object({ + Success: z.boolean(), + Error: z.string().nullable(), + Value: z.object({ ArePure: z.array(z.boolean()) }).nullable(), +}) + /** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */ export const KeepsakeConfig = z.object({ KeepsakeFeatureEnabled: z.boolean(), diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 63ad91d..22240cf 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { validateStudioBuildValidationToken } from '@repo/jwt' + import charadesAprilWords from '../../static/charades-april.json' import charadesWords from '../../static/charades.json' import communityBoard from '../../static/community-board.json' @@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http' import { AUTHED, BareString, + BulkAllowedRequest, + BulkAllowedResponse, + form, idParam, IsPureResponse, json, @@ -144,6 +149,42 @@ export const gameplayRoutes = new Hono({ strict: false }) } ) + // Studio checks all component text in one request, preserving duplicates and order. + .post( + '/api/sanitize/v2/isAllowed/bulk', + describeRoute({ + tags: ['Gameplay'], + summary: 'Check Studio component text in bulk', + description: + 'Checks repeated Values form fields using the existing profanity policy. ArePure matches the input order, including duplicate and empty strings. Accepts an account token or a dedicated Studio build-validation credential with no account identity.', + security: AUTHED, + requestBody: form(BulkAllowedRequest, 'Repeated Values fields'), + responses: { + 200: json(BulkAllowedResponse, 'Ordered results in a Studio result envelope'), + 400: json(BulkAllowedResponse, 'Invalid form values'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + if ( + (await authedId(c)) === null && + !(await validateStudioBuildValidationToken(c.req.raw, await c.env.JWT_SECRET.get())) + ) + return unauthorized(c) + const body = await c.req.parseBody({ all: true }).catch(() => null) + const field = body?.Values + const values = field === undefined ? [] : Array.isArray(field) ? field : [field] + if (body === null || values.some((value) => typeof value !== 'string')) { + return c.json({ Success: false, Error: 'Values must be strings.', Value: null }, 400) + } + return c.json({ + Success: true, + Error: null, + Value: { ArePure: values.map((value) => !containsSwears(value as string)) }, + }) + } + ) + // ---- Activities ----------------------------------------------------------- // Word bank for the Charades activity. The client requests the list by // activity name (`.../words/Charades`); other activities have no data yet. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 55a2640..a990a4b 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4756,6 +4756,59 @@ describe('public endpoints', () => { }) }) + describe('POST /api/sanitize/v2/isAllowed/bulk', () => { + const bulk = async (body: URLSearchParams | FormData, authed = true) => + exports.default.fetch(`${ORIGIN}/api/sanitize/v2/isAllowed/bulk`, { + method: 'POST', + headers: authed ? await bearer('42') : {}, + body, + }) + + test('401s without a token', async () => { + expect((await bulk(new URLSearchParams('Values=hello'), false)).status).toBe(401) + }) + + test('preserves repeated values, order, empty text and duplicates in the Studio envelope', async () => { + const body = new URLSearchParams() + for (const text of ['MakerRoom', 'fuck this', '', 'Scunthorpe', 'MakerRoom', 'sh1t']) + body.append('Values', text) + const res = await bulk(body) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Success: true, + Error: null, + Value: { ArePure: [true, false, true, true, true, false] }, + }) + }) + + test('accepts a single multipart value and an empty batch', async () => { + const body = new FormData() + body.append('Values', 'a$$hole') + expect(await (await bulk(body)).json()).toEqual({ + Success: true, + Error: null, + Value: { ArePure: [false] }, + }) + expect(await (await bulk(new URLSearchParams())).json()).toEqual({ + Success: true, + Error: null, + Value: { ArePure: [] }, + }) + }) + + test('rejects file values rather than marking them clean', async () => { + const body = new FormData() + body.append('Values', new Blob(['hello']), 'text.txt') + const res = await bulk(body) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + Success: false, + Error: 'Values must be strings.', + Value: null, + }) + }) + }) + describe('POST /api/sanitize/v1/isPure', () => { const isPure = async (Value?: string, authed = true) => exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, { @@ -10343,6 +10396,7 @@ describe('openapi', () => { 'POST /api/rooms/v1/verifyRole', 'POST /api/sanitize/v1', 'POST /api/sanitize/v1/isPure', + 'POST /api/sanitize/v2/isAllowed/bulk', 'POST /api/v1/progression/bulk', 'POST /outfits/bulk', 'POST /statsigUserProperties', diff --git a/apps/api/src/test/integration/studio-build-validation.test.ts b/apps/api/src/test/integration/studio-build-validation.test.ts new file mode 100644 index 0000000..9efd07a --- /dev/null +++ b/apps/api/src/test/integration/studio-build-validation.test.ts @@ -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) +}) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index f71ad66..667ce04 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -304,7 +304,9 @@ async function authedId(c: Context): Promise { } /** Whether one account row holds a role. Absent/false both mean no role. */ -function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean { +function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean { + // Matches accountTokenRoles: screenshare is a feature gate on every account token. + if (role === 'screenshare') return true return (role === 'developer' ? account.isDeveloper : account.isModerator) === true } @@ -314,7 +316,11 @@ function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean { * mirroring the reference API. An id that is not a number is answered as unknown (404) rather * than as a 400: `/role/developer/nonsense` is just an id that matches nothing. */ -async function roleAnswer(c: Context, role: 'developer' | 'moderator', id: string) { +async function roleAnswer( + c: Context, + role: 'developer' | 'moderator' | 'screenshare', + id: string +) { logger.info(`${role} role lookup`, { id }) const accountId = Number.parseInt(id, 10) const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId) @@ -1708,6 +1714,11 @@ const app = new Hono() roleFilterAnswer(c, 'moderator') ) + // Studio queries each room owner's screenshare role before validating video players. + .get('/role/screenshare/:id', describeRoute(roleLookup('screenshare')), (c) => + roleAnswer(c, 'screenshare', c.req.param('id')) + ) + // @guess Oculus nonce. The client asks for this before a Meta login; the exact shape // it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length // Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index 887abc6..b1f63fd 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -286,16 +286,18 @@ export const RestrictionDto = z.object({ DisplayReason: z.string().nullable().describe('Reason shown to the player. Free text'), }) -export function roleLookup(role: 'developer' | 'moderator') { +export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') { return { tags: ['Roles'], summary: `Whether a player has the ${role} role`, description: - `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` + - `granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` + - `in the access token's \`role\` claim, so the client rarely needs this route.\n\n` + - `This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` + - `different answer shape — which of several players hold the role, as an array.`, + role === 'screenshare' + ? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.' + : `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` + + `granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` + + `in the access token's \`role\` claim, so the client rarely needs this route.\n\n` + + `This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` + + `different answer shape — which of several players hold the role, as an array.`, parameters: [ { name: 'id', diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index bf661e7..1914d52 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1821,6 +1821,22 @@ describe('auth worker routes', () => { expect(await res.text()).toBe('') }) + test('GET /role/screenshare/:id matches the token feature gate for existing accounts', async () => { + for (const id of [42, 77]) { + const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`) + expect(res.status).toBe(200) + expect(await res.json()).toBe(true) + } + }) + + test('GET /role/screenshare/:id does not grant a role to unknown accounts', async () => { + for (const id of ['999999', 'nonsense']) { + const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`) + expect(res.status).toBe(404) + expect(await res.text()).toBe('') + } + }) + test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => { const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`) expect(res.status).toBe(200) @@ -2032,6 +2048,7 @@ describe('auth worker routes', () => { 'GET /role/developer/{id}', 'GET /role/moderator', 'GET /role/moderator/{id}', + 'GET /role/screenshare/{id}', 'POST /account/me/changepassword', 'POST /cachedlogin/forplatformid/{platform}/{id}', 'POST /cachedlogin/forplatformids', diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index cce568b..33d3c10 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -353,6 +353,22 @@ const app = new Hono() (c) => serveAsset(c, `avatar/${c.req.param('asset')}`) ) + // Editable Unity source manifests. Baked bundles are served separately. + .get( + '/roomunityasset/:filename{.+}', + describeRoute({ + tags: ['Assets'], + summary: 'Download a Studio authoring manifest', + parameters: [keyParam('filename', 'Uploaded JSON source manifest', true)], + responses: assetResponses('The source manifest'), + }), + async (c) => { + const name = c.req.param('filename') + if (name.includes('..')) return c.body(null, 400) + return serveAsset(c, `roomunityasset/${name}`) + } + ) + // Rec Room Studio room bundles. The filename is the one a room save or // `GET …/unityasset` advertises — the R2 key itself (`studio//…`), as // `unity_asset.filename` records it. Only a recorded filename is served, so this @@ -426,16 +442,14 @@ async function serveUnityAsset(c: Context) { const name = c.req.param('filename') ?? '' if (name.includes('..')) return c.body(null, 400) const key = await unityAssetR2Key(c.env.DB, name) - if (!key) return c.notFound() - return serveAsset(c, key) + return serveAsset(c, key ?? `unityasset/${name}`) } /** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */ async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise { if (name.includes('..')) return new Response(null, { status: 400 }) const key = await unityAssetR2Key(env.DB, name) - if (!key) return new Response(null, { status: 404 }) - const head = await env.CDN_ASSETS.head(key) + const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`) if (!head) return new Response(null, { status: 404 }) const headers = new Headers() diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index 13c49ab..00033eb 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -362,6 +362,7 @@ describe('cdn endpoints', () => { 'GET /data/{id}', 'GET /invention/{dataBlob}', 'GET /room/{dataBlob}', + 'GET /roomunityasset/{filename}', 'GET /sigs/{sigName}', 'GET /unityasset/{filename}', 'HEAD /unityasset/{filename}', @@ -377,3 +378,27 @@ describe('cdn endpoints', () => { expect(JSON.stringify(spec).includes('"$ref"')).toBe(false) }) }) + +test('Studio authoring source manifests and source files remain distinct from baked bundles', async () => { + await env.CDN_ASSETS.put( + 'roomunityasset/studio-source-fixture.json', + '{"Version":3,"Files":[]}', + { httpMetadata: { contentType: 'application/json' } } + ) + const source = await exports.default.fetch(`${ORIGIN}/roomunityasset/studio-source-fixture.json`) + expect(source.status).toBe(200) + expect(await source.json()).toEqual({ Version: 3, Files: [] }) + await env.CDN_ASSETS.put('unityasset/studio-source-fixture.txt', 'source data') + const file = await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`) + expect(await file.text()).toBe('source data') + expect( + ( + await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`, { + method: 'HEAD', + }) + ).status + ).toBe(200) + expect((await exports.default.fetch(`${ORIGIN}/roomunityasset/missing-source.json`)).status).toBe( + 404 + ) +}) diff --git a/apps/notify/src/notifications-hub.ts b/apps/notify/src/notifications-hub.ts index d36ac40..353f8c9 100644 --- a/apps/notify/src/notifications-hub.ts +++ b/apps/notify/src/notifications-hub.ts @@ -31,6 +31,7 @@ const RS = '\u001e' interface SocketState { connectionId: string handshakeDone: boolean + channels?: string[] /** The player this socket belongs to, from the token validated at connect. */ playerId?: number } @@ -356,6 +357,38 @@ export class NotificationsHub extends DurableObject { private handleInvocation(ws: WebSocket, connectionId: string, msg: HubMessage): void { switch (msg.target) { + case 'SubscribeTo': + case 'UnsubscribeFrom': { + const argument = msg.arguments?.[0] as + { Channels?: unknown; channels?: unknown } | undefined + const channels = argument?.Channels ?? argument?.channels + if ( + !Array.isArray(channels) || + channels.length > 128 || + channels.some( + (channel) => typeof channel !== 'string' || !channel.length || channel.length > 256 + ) + ) { + if (msg.invocationId) + ws.send(this.completionError(msg.invocationId, 'Invalid channel subscriptions.')) + break + } + const state = ws.deserializeAttachment() as SocketState + const current = new Set(state.channels ?? []) + for (const channel of channels) { + if (msg.target === 'SubscribeTo') current.add(channel) + else current.delete(channel) + } + if (current.size > 128) { + if (msg.invocationId) + ws.send(this.completionError(msg.invocationId, 'Too many channel subscriptions.')) + break + } + state.channels = [...current] + ws.serializeAttachment(state) + if (msg.invocationId) ws.send(this.completion(msg.invocationId, null)) + break + } case 'SubscribeToPlayers': { const playerIds = parsePlayerIds(msg.arguments) // An argument we can't read is not "subscribe to nobody": subscribing diff --git a/apps/notify/src/test/integration/api.test.ts b/apps/notify/src/test/integration/api.test.ts index d1eb131..6359b62 100644 --- a/apps/notify/src/test/integration/api.test.ts +++ b/apps/notify/src/test/integration/api.test.ts @@ -65,6 +65,7 @@ const DEFAULT_CONNECT_PLAYER = 9000 interface HubRecord { type?: number + error?: string target?: string invocationId?: string result?: unknown @@ -197,6 +198,44 @@ describe('internal endpoint auth', () => { }) describe('hub protocol', () => { + test('Studio named subscriptions merge and unsubscribe', async () => { + const { ws, waitFor } = await connect('conn-studio-channels') + const stub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + for (const [i, channel] of ['RRS:first', 'RRS:second', 'RRS:first'].entries()) { + send(ws, { + type: 1, + invocationId: `s${i}`, + target: 'SubscribeTo', + arguments: [{ Channels: [channel] }], + }) + expect( + (await waitFor((r) => r.type === 3 && r.invocationId === `s${i}`)).error + ).toBeUndefined() + } + const channels = () => + runInDurableObject( + stub, + (_instance, state) => + state + .getWebSockets() + .map( + (socket) => + socket.deserializeAttachment() as { connectionId: string; channels?: string[] } + ) + .find((attachment) => attachment.connectionId === 'conn-studio-channels')?.channels + ) + expect(await channels()).toEqual(['RRS:first', 'RRS:second']) + send(ws, { + type: 1, + invocationId: 'u', + target: 'UnsubscribeFrom', + arguments: [{ Channels: ['RRS:first'] }], + }) + expect((await waitFor((r) => r.type === 3 && r.invocationId === 'u')).error).toBeUndefined() + expect(await channels()).toEqual(['RRS:second']) + ws.close() + }) + test('GetSubscriptions reflects SubscribeToPlayers', async () => { const { ws, waitFor } = await connect('conn-subs') diff --git a/apps/rooms/README.md b/apps/rooms/README.md index a33d5ef..9268805 100644 --- a/apps/rooms/README.md +++ b/apps/rooms/README.md @@ -38,8 +38,12 @@ Reads are the exception: they carry no envelope at all. A paged read (the save h same number, because the client's paged DTO and the reference disagree on the name. `GET /rooms/{roomId}` puts a Studio build's main bundles on `CurrentSave.UnitySubAssets` (target 0 Windows, 2 Android/Quest). `unityAssetTarget` keeps only that target. Maker-pen -saves keep the arrays empty. `GET …/unityasset?unityAssetId=` is the bare metadata for -those files; the bytes are on the CDN. +saves keep the arrays empty. `GET …/unityasset?unityAssetId=` returns the +editable source manifest's filename and hash, plus available baked-build metadata. +The current published source is public; other revisions require save-history +permissions. Without an asset ID, authorized save readers receive the latest +source, while other callers receive only the current published source. A save +with only baked bundles has no editable source and returns 404. `POST /unity_assets/baked/bulk` (form `target=0&version=3&id=&id=`) is the 2025 client's bulk lookup of baked bundles from the `unity_asset` table — a bare array of `{ UnityAssetId, Target, Version, Filename, Hash }`, one per asset that has a build. diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 6891ade..ac47b2d 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -192,8 +192,11 @@ export const SubRoomDataSaveResponseDto = z.object({ .string() .nullable() .describe('Base64 SHA-256 of `unityAsset` when that file is set; otherwise null'), - dataBlob: z.string(), + dataBlob: z.string().nullable(), dataBlobHash: z.string().nullable().describe('Echoed from the request’s `SubRoomData.Hash`'), + persistenceVersion: z.int(), + omVersion: z.int(), + ugcSubVersion: z.int(), savedByAccountId: z.int().nullable(), savedOnPlatform: z .int() @@ -225,11 +228,11 @@ export const UnitySubAssetDto = z.object({ export const SubRoomDataSaveDto = z.object({ UnitySubAssets: z .array(UnitySubAssetDto) - .describe('Main Studio bundles for this save; empty for a maker-pen save'), + .describe('Subasset dependencies, excluding the main bundle; empty when none'), ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'), SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'), SubRoomId: z.int().describe('The owning subroom — re-pointed when a subroom is cloned'), - DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'), + DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'), ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'), PersistenceVersion: z.int(), OMVersion: z.int(), @@ -439,7 +442,7 @@ export const RoomBoostCount = z * A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name * lookups answer an empty object rather than a 404 — the client reads that as "no room". */ -export const RoomLookup = z.union([RoomDto, z.object({})]) +export const RoomLookup = z.union([RoomDto, z.object({}), z.null()]) /** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */ export const MissingLookupParam = z @@ -588,6 +591,14 @@ export const RoomEnvelope = z.object({ value: RoomDto.nullable(), }) +/** Result consumed by Studio's v2 settings API. */ +export const StudioRoomEnvelope = z.object({ + Success: z.boolean(), + Error: z.string().nullable(), + Value: RoomDto.nullable(), +}) +export const RoomRestrictionsEnvelope = z.union([RoomEnvelope, StudioRoomEnvelope]) + /** * What `POST /rooms/{roomId}/subrooms/{subRoomId}/data` answers: `value` carries BOTH the * updated room and the save that was just created. Note `error` is NULL here, not the @@ -796,6 +807,7 @@ export const BulkRoomsRequest = z.object({ * the fields actually posted are changed, and the names are matched case-insensitively. */ export const RestrictionsRequest = z.object({ + ageRating: z.string().optional().describe('0 unset, 1 all ages, 2 ages 13 and up'), supportsScreens: z.string().optional().describe('`True` / `False`'), supportsWalkVR: z.string().optional().describe('`True` / `False`'), supportsTeleportVR: z.string().optional().describe('`True` / `False`'), @@ -949,6 +961,8 @@ export const SaveSubRoomDataRequest = z.object({ .optional() .describe('The save comment — a description of THIS revision, not the room’s description'), PersistenceVersion: z.int().optional().describe('Recorded on the save and the subroom'), + OMVersion: z.int().optional().describe('Object-model format version; retained when omitted'), + UgcSubVersion: z.int().optional().describe('UGC format revision; retained when omitted'), InventionUsage: z.string().optional().describe('Recorded on the subroom'), UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'), AutoPublish: z @@ -982,7 +996,7 @@ export const SubRoomDataSaveNoUnityAssetsDto = z.object({ SubRoomId: z.int(), UnityAssetId: z.string().nullable().describe('Null unless the save carried one'), ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'), - DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'), + DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'), DataBlobHash: z.string().nullable(), PersistenceVersion: z.int(), OMVersion: z.int(), @@ -1027,7 +1041,7 @@ export const SubRoomDataSaveDetailDto = z.object({ SubRoomId: z.int(), UnityAssetId: z.string().nullable().describe('Null unless the save carried one'), ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty — we record none'), - DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'), + DataBlob: z.string().nullable().describe('Scene-data key; null uses the built-in empty template'), DataBlobHash: z.string().nullable(), PersistenceVersion: z.int(), OMVersion: z.int(), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 470cc86..fa6e496 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -16,6 +16,7 @@ import { cloneSubRoom, countRoomsByCreator, createNotification, + createStudioRoom, createSubRoom, deleteExpiredRoomInvites, deleteRoom, @@ -46,7 +47,6 @@ import { getRoomsByIds, getSimilarRooms, getStudioUnityAsset, - getSubRoom, getSubRoomPermissions, getSubRoomSaveById, getSubRoomSaves, @@ -159,6 +159,7 @@ import { roomIdParam, RoomLookup, RoomPascalEnvelope, + RoomRestrictionsEnvelope, RoomResultEnvelope, RoomRoleDto, RoomSaveEnvelope, @@ -933,11 +934,15 @@ function toSaveResponse(save: Record, target: number | null = n unityAssetId: str(save.UnityAssetId), unityAsset: baked?.filename ?? null, unityAssetHash: baked?.hash ?? null, - dataBlob: str(save.DataBlob) ?? '', + dataBlob: str(save.DataBlob), dataBlobHash: str(save.DataBlobHash), + persistenceVersion: num(save.PersistenceVersion) ?? 0, + omVersion: num(save.OMVersion) ?? 0, + ugcSubVersion: num(save.UgcSubVersion) ?? 0, savedByAccountId: num(save.SavedByAccountId), - savedOnPlatform: num(save.SavedOnPlatform) ?? 0, - savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0, + savedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0), + savedOnDeviceClass: + save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0), description: str(save.Description), createdAt: str(save.CreatedAt) ?? '', } @@ -1014,13 +1019,14 @@ function toSaveWithoutUnityAssets(save: Record) { ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds) ? save.ReferencedUnityAssetIds : [], - DataBlob: str(save.DataBlob) ?? '', + DataBlob: str(save.DataBlob), DataBlobHash: str(save.DataBlobHash), PersistenceVersion: num(save.PersistenceVersion) ?? 0, OMVersion: num(save.OMVersion) ?? 0, SavedByAccountId: num(save.SavedByAccountId), - SavedOnPlatform: num(save.SavedOnPlatform) ?? 0, - SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0, + SavedOnPlatform: save.SavedOnPlatform === null ? null : (num(save.SavedOnPlatform) ?? 0), + SavedOnDeviceClass: + save.SavedOnDeviceClass === null ? null : (num(save.SavedOnDeviceClass) ?? 0), Description: str(save.Description) ?? '', ModerationState: num(save.ModerationState) ?? 0, CreatedAt: str(save.CreatedAt) ?? '', @@ -1028,6 +1034,17 @@ function toSaveWithoutUnityAssets(save: Record) { } } +/** A JSON authoring manifest is not interchangeable with a baked game bundle. */ +function studioSource( + save: Record +): { Filename: string; Hash: string | null } | null { + const value = save.StudioSourceAsset + if (typeof value !== 'object' || value === null) return null + const source = value as { Filename?: unknown; Hash?: unknown } + if (typeof source.Filename !== 'string' || source.Filename === '') return null + return { Filename: source.Filename, Hash: typeof source.Hash === 'string' ? source.Hash : null } +} + /** * One save as `GET …/subrooms/:sid/saves/:saveId` answers it — the PascalCase row the * `…/saves` list serves, NOT the camelCase `subRoomDataSave` the room save that created @@ -1035,10 +1052,10 @@ function toSaveWithoutUnityAssets(save: Record) { * values were all missing). Two things differ from the list row, both observed against * the reference: * - * - Each `UnitySubAssets` entry is `{ UnityAssetId, UnityAsset, UnityAssetHash }` — the - * stored `Filename`/`Hash` under the names the client's save-detail decoder reads — - * and the save's own main bundle is lifted to top-level `UnityAsset`/`UnityAssetHash` - * (the named target's, Windows when none was named; both null for a maker-pen save). + * - The save's main bundle is at top-level `UnityAsset`/`UnityAssetHash`. Only other + * bundles belong in `UnitySubAssets`, as `{ UnityAssetId, UnityAsset, UnityAssetHash }`. + * Concrete filenames use the named target, Windows when absent; the main fields + * are null for a maker-pen save. * - No `Tags`, and `UnityAssetId` is always present (null when the save carried none). * * Built key by key like {@link toSaveWithoutUnityAssets}, for the same reason. @@ -1047,11 +1064,18 @@ function toSaveDetail(save: Record, target: number | null) { const str = (v: unknown) => (typeof v === 'string' ? v : null) const num = (v: unknown) => (typeof v === 'number' ? v : null) const main = pickBakedBundle(save, target) + // The root bundle is loaded separately through UnityAsset. Listing it as a + // dependency makes Unity load the same serialized files twice (room error 8714). + // A detail response names concrete files, so default to Windows just as `main` + // does; it cannot carry both platform builds under the same asset GUID. + const rootId = str(save.UnityAssetId)?.toLowerCase() const bundles = (Array.isArray(save.UnitySubAssets) ? save.UnitySubAssets : []).filter( (item): item is Record => typeof item === 'object' && item !== null && - (target === null || (item as { Target?: unknown }).Target === target) + (item as { Target?: unknown }).Target === (target ?? 0) && + (typeof (item as { UnityAssetId?: unknown }).UnityAssetId !== 'string' || + (item as { UnityAssetId: string }).UnityAssetId.toLowerCase() !== rootId) ) return { UnitySubAssets: bundles.map((row) => ({ @@ -1070,7 +1094,7 @@ function toSaveDetail(save: Record, target: number | null) { ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds) ? save.ReferencedUnityAssetIds : [], - DataBlob: str(save.DataBlob) ?? '', + DataBlob: str(save.DataBlob), DataBlobHash: str(save.DataBlobHash), PersistenceVersion: num(save.PersistenceVersion) ?? 0, OMVersion: num(save.OMVersion) ?? 0, @@ -1121,7 +1145,9 @@ async function ownedRooms(c: Context) { async function ownedRoomsExcludingDorm(c: Context) { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) - const rooms = await getRoomsByCreator(c.env.DB, accountId) + const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) => + canManageRoom(room, accountId) + ) return c.json(rooms.filter((r) => r.IsDorm !== true)) } @@ -1278,10 +1304,42 @@ const app = new Hono() if (idParam) { const id = firstId(idParam) const room = id === undefined ? null : await getRoomById(c.env.DB, id) - return c.json(room ?? {}) + return c.json(room ?? (c.req.query('include') === '525581' ? null : {})) } const room = await getRoomByName(c.env.DB, nameParam ?? '') - return c.json(room ?? {}) + return c.json(room ?? (c.req.query('include') === '525581' ? null : {})) + } + ) + + // Studio creates an empty room directly, rather than cloning a game template. + .post( + '/rooms/create', + describeRoute({ + tags: ['My rooms'], + summary: 'Create an empty Studio room', + security: AUTHED, + requestBody: form(CloneRoomRequest, 'Room name and optional ugcVersion (1 or 2)'), + responses: { + 200: json(RoomDto, 'The private room with its empty Home subroom'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const body = await c.req.parseBody() + const name = typeof body.name === 'string' ? body.name.trim() : '' + const invalid = roomNameRejection(name, 'room name') + if (name === '' || invalid !== null) + return c.json({ error: invalid ?? 'A room name is required.' }, 400) + const version = Number(body.ugcVersion ?? 1) + if (version !== 1 && version !== 2) return c.json({ error: 'Unsupported UGC version.' }, 400) + const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT) + if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) + return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403) + const room = await createStudioRoom(c.env.DB, name, accountId, version) + if (!room) return c.json({ error: 'A room with that name already exists.' }, 409) + return c.json(room) } ) @@ -1688,9 +1746,9 @@ const app = new Hono() '/rooms/ownedby/me', describeRoute({ tags: ['My rooms'], - summary: 'Rooms the caller owns (excluding their dorm)', + summary: 'Rooms the caller owns or co-owns (excluding their dorm)', description: [ - 'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a', + 'The caller’s created and accepted co-owned rooms with the dorm filtered out: a dorm is auto-provisioned, not a', 'room the player made, so it doesn’t belong in the “rooms you own” list. Use', '`createdby/me` for everything the account created. Accessibility is deliberately NOT', 'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike', @@ -3553,8 +3611,9 @@ const app = new Hono() // Auth-gated (401) and owner/co-owner-only (403). Body is a form of // `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present // are changed. Returns the updated room in the `{ success, error, value }` envelope. - .put( - '/rooms/:roomId{[0-9]+}/restrictions', + .on( + 'PUT', + ['/rooms/:roomId{[0-9]+}/restrictions', '/rooms/:roomId{[0-9]+}/v2/restrictions'], describeRoute({ tags: ['Room settings'], summary: 'Set a room’s platform/movement support flags', @@ -3567,7 +3626,10 @@ const app = new Hono() parameters: [roomIdParam], requestBody: form(RestrictionsRequest, 'The flags to change'), responses: { - 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), + 200: json( + RoomRestrictionsEnvelope, + 'The updated room; Studio v2 uses a PascalCase result envelope' + ), 401: UNAUTHORIZED_RESPONSE, 403: FORBIDDEN_RESPONSE, }, @@ -3584,7 +3646,7 @@ const app = new Hono() if (!canManageRoom(room, accountId)) return c.body(null, 403) const body = (await c.req.parseBody().catch(() => ({}))) as Record - const patch: Record = {} + const patch: Record = {} for (const [key, value] of Object.entries(body)) { const field = RESTRICTION_FIELDS[key.toLowerCase()] if (field !== undefined && typeof value === 'string') { @@ -3592,8 +3654,20 @@ const app = new Hono() } } + const ageEntry = Object.entries(body).find(([key]) => key.toLowerCase() === 'agerating') + if (ageEntry !== undefined) { + const age = ageEntry[1] + if (typeof age !== 'string' || !/^[012]$/.test(age)) { + if (c.req.path.endsWith('/v2/restrictions')) + return c.json({ Success: false, Error: 'Invalid age rating.', Value: null }) + return roomEnvelope(c, null, 'Invalid age rating.') + } + patch.AgeRating = Number(age) + } const updated = await updateRoomFields(c.env.DB, roomId, room, patch) await pushRoomUpdate(c, accountId, updated) + if (c.req.path.endsWith('/v2/restrictions')) + return c.json({ Success: true, Error: null, Value: updated }) return roomEnvelope(c, updated) } ) @@ -3925,7 +3999,14 @@ const app = new Hono() // `TotalResults` counts the whole history, not the page — that is what the client // pages against. return c.json({ - Results: page.map(toSaveWithoutUnityAssets), + Results: page.map((save) => { + const result = toSaveWithoutUnityAssets(save) + // Archived game bundles have no editable Unity project. Studio can still + // load their Maker Pen data; don't make it fetch a nonexistent source manifest. + if (c.req.header('user-agent')?.startsWith('RRStudio') && !studioSource(save)) + result.UnityAssetId = null + return result + }), TotalResults: saves.length, TotalCount: saves.length, }) @@ -4003,24 +4084,22 @@ const app = new Hono() } ) - // Metadata for the unity asset on one of this subroom's saves. The editor calls - // this after a build; the game reads the same bundles from `CurrentSave` and then - // downloads `filename` from the CDN. Bare object, no envelope, no auth — the room - // document already names the files. + // The authoring manifest on a save. Its JSON source filename is independent of + // the baked game bundles; a baked-only archive has no editable source to return. .get( '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/unityasset', describeRoute({ tags: ['Subrooms'], summary: 'The unity asset attached to a subroom save', description: [ - 'The baked Windows and Android bundles stored for `unityAssetId`, when that id is', + 'The editable JSON source manifest and any baked bundles stored for `unityAssetId`, when that id is', 'on a save of this subroom. A bare object: `unityAssetId`, `createdByAccountId`,', '`bakedUnityAssets` (one entry per main bundle), plus `filename` and `hash` for the', - 'Windows bundle. Bytes are `GET /unityasset/{filename}` on the CDN.', + 'authoring manifest. Source JSON is `GET /roomunityasset/{filename}` on the CDN; baked bundles remain separate.', '', - '`target` is 0 for Windows and 2 for Android/Quest. Stripped bundles are stored and', - 'not listed. An unknown room, subroom, or asset is a 404. No auth: `GET /rooms/{id}`', - 'already includes the same filenames on `CurrentSave`.', + 'Stripped bundles are not listed. The published current save is public. Other revisions require the same', + 'authentication and permissions as save history. Without an asset ID, authorized', + 'readers receive the latest source; other callers receive only the published source.', ].join(' '), parameters: [ roomIdParam, @@ -4029,6 +4108,8 @@ const app = new Hono() ], responses: { 200: json(UnityAssetWithSourceDto, 'The unity asset and its baked bundles'), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, 404: { description: 'No such room, subroom, or asset on that subroom' }, }, }), @@ -4036,27 +4117,40 @@ const app = new Hono() const roomId = Number.parseInt(c.req.param('roomId'), 10) const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) const unityAssetId = c.req.query('unityAssetId') ?? '' - if (unityAssetId === '') return c.notFound() - const sub = await getSubRoom(c.env.DB, roomId, subRoomId) - if (!sub) return c.notFound() - const saves = await getSubRoomSaves(c.env.DB, subRoomId) - const save = saves.find((row) => row.UnityAssetId === unityAssetId) + const room = await getRoomById(c.env.DB, roomId) + const sub = room && findSubRoom(room, subRoomId) + if (!room || !sub) return c.notFound() + const current = sub.CurrentSave + let save = + typeof current === 'object' && current !== null + ? (current as Record) + : undefined + const accountId = await authedAccountId(c) + const canReadHistory = accountId !== null && (await canReadSaves(c, room, roomId, accountId)) + if (canReadHistory) { + const saves = await getSubRoomSaves(c.env.DB, subRoomId) + save = + unityAssetId === '' + ? saves.find((row) => studioSource(row) !== null) + : saves.find((row) => row.UnityAssetId === unityAssetId) + } else if (unityAssetId !== '' && save?.UnityAssetId !== unityAssetId) { + if (accountId === null) return unauthorized(c) + return c.body(null, 403) + } if (!save) return c.notFound() - - const asset = await getStudioUnityAsset(c.env.DB, unityAssetId) - if (!asset) return c.notFound() - - // Main bundles, Windows first; the Windows one (or the first there is) supplies - // the top-level `filename`/`hash`. - const mains = bakedUnityAssets(asset.builds) - const source = mains.find((build) => build.Target === 0) ?? mains[0] + const source = studioSource(save) if (!source) return c.notFound() + const asset = await getStudioUnityAsset(c.env.DB, String(save.UnityAssetId)) + + // Baked bundles are optional until compilation; the source exists before a build. + const mains = bakedUnityAssets(asset?.builds ?? []) + const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0 return c.json({ - unityAssetId: asset.unityAssetId, - createdByAccountId: asset.createdByAccountId || savedBy, + unityAssetId: String(save.UnityAssetId), + createdByAccountId: asset?.createdByAccountId || savedBy, bakedUnityAssets: mains.map((build) => ({ unityAssetId: build.UnityAssetId, target: build.Target, @@ -4132,8 +4226,11 @@ const app = new Hono() RoomData?: { Filename?: string; Hash?: string | null } SubRoomData?: { Filename?: string; Hash?: string | null } UnityAssetId?: string | null + UnityAsset?: { Filename?: string; Hash?: string | null } Description?: string PersistenceVersion?: number + OMVersion?: number + UgcSubVersion?: number InventionUsage?: string AutoPublish?: boolean } @@ -4145,10 +4242,19 @@ const app = new Hono() roomDataFilename: body.RoomData?.Filename, roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined, unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined, + unitySource: + typeof body.UnityAsset?.Filename === 'string' && body.UnityAsset.Filename !== '' + ? { + Filename: body.UnityAsset.Filename, + Hash: typeof body.UnityAsset.Hash === 'string' ? body.UnityAsset.Hash : null, + } + : undefined, autoPublish: body.AutoPublish === true, description: typeof body.Description === 'string' ? body.Description : undefined, persistenceVersion: typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined, + omVersion: typeof body.OMVersion === 'number' ? body.OMVersion : undefined, + ugcSubVersion: typeof body.UgcSubVersion === 'number' ? body.UgcSubVersion : undefined, inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined, }) if (!result) { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 53a452b..3888e0d 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -3744,6 +3744,9 @@ describe('rooms endpoints', () => { // The save is a camelCase projection, NOT the PascalCase CurrentSave shape. expect(saved.value.subRoomDataSave).toEqual({ + persistenceVersion: 41, + omVersion: 0, + ugcSubVersion: 0, subRoomDataSaveId: expect.any(Number), subRoomId: 2, unityAssetId: null, @@ -6076,6 +6079,7 @@ describe('rooms endpoints', () => { 'GET /rooms/{roomId}/bans', 'GET /rooms/{roomId}/bans/history', 'GET /rooms/{roomId}/bans/{playerId}/isBanned', + 'GET /rooms/{roomId}/boosts/{playerId}/count', 'GET /rooms/{roomId}/experience', 'GET /rooms/{roomId}/experience/player', 'GET /rooms/{roomId}/interactionby/me', @@ -6089,6 +6093,7 @@ describe('rooms endpoints', () => { 'GET /roomserver/rooms/createdby/me', 'GET /showcase/{playerId}', 'POST /rooms/bulk', + 'POST /rooms/create', 'POST /rooms/{roomId}/bans', 'POST /rooms/{roomId}/clone', 'POST /rooms/{roomId}/experience', @@ -6119,6 +6124,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions', 'PUT /rooms/{roomId}/tags', + 'PUT /rooms/{roomId}/v2/restrictions', 'PUT /rooms/{roomId}/warning', ]) @@ -6135,7 +6141,7 @@ describe('rooms endpoints', () => { // `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build // table must not fail the room read. describe('studio room bundles', () => { - const ASSET = '11111111-2222-4333-8444-555555555555' + const ASSET = 'aa111111-2222-4333-8444-555555555555' const winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main') const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main') const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' @@ -6157,7 +6163,12 @@ describe('studio room bundles', () => { Tags: [], ModerationState: 0, CreatedAt: '2026-10-03T00:00:00.000Z', - ...(unityAssetId ? { UnityAssetId: unityAssetId } : {}), + ...(unityAssetId + ? { + UnityAssetId: unityAssetId, + StudioSourceAsset: { Filename: 'source-manifest.json', Hash: 'source-hash' }, + } + : {}), }) async function reset() { @@ -6294,20 +6305,24 @@ describe('studio room bundles', () => { { unityAssetId: ASSET, target: 0, version: 1, filename: winName }, { unityAssetId: ASSET, target: 2, version: 1, filename: androidName }, ], - filename: winName, - hash: winHash, + filename: 'source-manifest.json', + hash: 'source-hash', }) - expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(404) + expect((await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/unityasset`)).status).toBe(200) expect( ( await SELF.fetch( - `${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000` + `${ORIGIN}/rooms/9901/subrooms/99011/unityasset?unityAssetId=00000000-0000-4000-8000-000000000000`, + { headers: await bearer('1') } ) ).status ).toBe(404) expect( - (await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`)) - .status + ( + await SELF.fetch(`${ORIGIN}/rooms/9902/subrooms/99021/unityasset?unityAssetId=${ASSET}`, { + headers: await bearer('1'), + }) + ).status ).toBe(404) const detail = await SELF.fetch( @@ -6316,15 +6331,12 @@ describe('studio room bundles', () => { ) expect(detail.status).toBe(200) // The detail lifts the Windows bundle to the top level when no target is named, and - // names every bundle the client's way — `UnityAsset`/`UnityAssetHash`, not the stored - // `Filename`/`Hash`. + // lists only dependencies in UnitySubAssets. Repeating the root there makes the + // game load it twice and Unity rejects the second load with room error 8714. expect(await detail.json()).toMatchObject({ UnityAsset: winName, UnityAssetHash: winHash, - UnitySubAssets: [ - { UnityAssetId: ASSET, UnityAsset: winName, UnityAssetHash: winHash }, - { UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash }, - ], + UnitySubAssets: [], }) const questDetail = await SELF.fetch( @@ -6334,9 +6346,7 @@ describe('studio room bundles', () => { expect(await questDetail.json()).toMatchObject({ UnityAsset: androidName, UnityAssetHash: androidHash, - UnitySubAssets: [ - { UnityAssetId: ASSET, UnityAsset: androidName, UnityAssetHash: androidHash }, - ], + UnitySubAssets: [], }) const light = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/saves/no_unity_assets`, { @@ -6358,6 +6368,35 @@ describe('studio room bundles', () => { }) }) + it('never loads the main bundle again as a dependency, including mixed-case GUIDs', async () => { + await reset() + await storeFiles() + await env.DB.prepare( + "UPDATE subroom_save SET data=json_set(data, '$.UnityAssetId', ?1) WHERE sub_room_id=99011" + ) + .bind(ASSET.toUpperCase()) + .run() + const row = await env.DB.prepare( + 'SELECT current_save_id FROM subroom WHERE sub_room_id=99011' + ).first<{ current_save_id: number }>() + for (const [query, filename] of [ + ['', winName], + ['?unityAssetTarget=0', winName], + ['?unityAssetTarget=2', androidName], + ['?unityAssetTarget=1', null], + ] as const) { + const response = await SELF.fetch( + `${ORIGIN}/rooms/9901/subrooms/99011/saves/${row!.current_save_id}${query}`, + { headers: await bearer('1') } + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + UnityAsset: filename, + UnitySubAssets: [], + }) + } + }) + // A FIRST-PARTY room's bundle — captured from the official game and loaded by hand // under its `unity//` layout — shares `unity_asset` with Studio builds but is // not UGC, and must not be folded onto the save: the 2025 client handed it inline @@ -6682,3 +6721,378 @@ describe('Studio baked Unity asset downloads', () => { expect(await (await bulk({ UnityAssetIds: [mainId] })).json()).toEqual([]) }) }) + +describe('Studio room management contracts', () => { + it('lists private co-owned rooms, but excludes hosts and pending invites', async () => { + for (const [id, role, invited] of [ + [91001, 30, 0], + [91002, 10, 0], + [91003, 0, 30], + ]) { + await seedRoomWithSubRooms(env.DB, { + RoomId: id, + Name: `StudioRole${id}`, + CreatorAccountId: 999, + Accessibility: 0, + Roles: [{ AccountId: 880, Role: role, InvitedRole: invited }], + SubRooms: [], + }) + } + const rooms = (await ( + await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('880') }) + ).json()) as Array<{ RoomId: number }> + expect(rooms.map((r) => r.RoomId)).toEqual([91001]) + expect( + await ( + await SELF.fetch(`${ORIGIN}/rooms/createdby/me`, { headers: await bearer('880') }) + ).json() + ).toEqual([]) + }) + it('returns null for Studio name validation and preserves the legacy empty object', async () => { + expect( + await (await SELF.fetch(`${ORIGIN}/rooms?name=StudioUnusedName&include=525581`)).json() + ).toBeNull() + expect(await (await SELF.fetch(`${ORIGIN}/rooms?name=StudioUnusedName`)).json()).toEqual({}) + }) + it('creates a private empty R2 room and rejects a case-insensitive duplicate', async () => { + const create = async (name: string, auth = true) => + SELF.fetch(`${ORIGIN}/rooms/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(auth ? await bearer('880') : {}), + }, + body: new URLSearchParams({ name, ugcVersion: '2' }).toString(), + }) + expect((await create('StudioFreshRoom', false)).status).toBe(401) + const response = await create('StudioFreshRoom') + expect(response.status).toBe(200) + const room = (await response.json()) as Room + expect(room).toMatchObject({ + Name: 'StudioFreshRoom', + UgcVersion: 2, + Accessibility: 0, + CreatorAccountId: 880, + Tags: [], + LoadScreens: [], + RestrictedCircuitsAllowListNames: [], + PublishedAt: null, + }) + expect(room.SubRooms).toEqual([ + expect.objectContaining({ + Name: 'Home', + CreatorAccountId: 880, + CurrentSave: expect.objectContaining({ DataBlob: null, OMVersion: 198 }), + UnitySceneId: '5d4e40d8-f289-4295-a6e1-4f907835007d', + }), + ]) + const added = await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(await bearer('880')) }, + body: 'name=Second', + }) + expect(added.status).toBe(200) + const lookup = await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}?include=525581`) + const refreshed = await lookup.json< + Room & { SubRooms: Array<{ SubRoomId: number; CurrentSave: unknown }> } + >() + expect(refreshed.SubRooms).toHaveLength(2) + for (const sub of refreshed.SubRooms) { + expect(sub.CurrentSave).toMatchObject({ DataBlob: null, OMVersion: 198 }) + } + const initial = refreshed.SubRooms[0]!.CurrentSave as { SubRoomDataSaveId: number } + const subRoomId = Number(refreshed.SubRooms[0]!.SubRoomId) + const initialSave = await SELF.fetch( + `${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms/${subRoomId}/saves/${initial.SubRoomDataSaveId}`, + { headers: await bearer('880') } + ) + expect(initialSave.status).toBe(200) + expect(await initialSave.json()).toMatchObject({ DataBlob: null, OMVersion: 198 }) + for (const fields of [{ OMVersion: 197, UgcSubVersion: 3 }, {}]) { + const saved = await SELF.fetch( + `${ORIGIN}/rooms/${Number(room.RoomId)}/subrooms/${subRoomId}/data`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('880')) }, + body: JSON.stringify({ ...fields, AutoPublish: true }), + } + ) + expect(saved.status).toBe(200) + const updated = await ( + await SELF.fetch(`${ORIGIN}/rooms/${Number(room.RoomId)}?include=525581`) + ).json }>() + 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() + 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 }>() + 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() + 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() + 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()).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> } + 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 }> + } + 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> } + 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' }) + }) +}) diff --git a/apps/storage/README.md b/apps/storage/README.md index ed4b6f3..3d759df 100644 --- a/apps/storage/README.md +++ b/apps/storage/README.md @@ -21,3 +21,19 @@ pnpm test ```sh pnpm turbo deploy ``` + +## Studio uploads + +`POST /upload/unityassets` accepts authenticated repeated multipart file parts, +including the gzip-compressed multipart requests sent by Studio. It returns one +`{ Filename, Hash }` per part in request order. `Hash` is base64 SHA-256; +`Filename` matches the SDK's content-addressed source manifest: unsigned +little-endian SHA-256 encoded as base36, padded to 50 characters and truncated to +its first 40. Retrying identical content therefore returns the same names. +Source bytes are stored under `unityasset/` 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. diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 1b49521..23ed89c 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -51,6 +51,9 @@ const UPLOAD_SUBFOLDER: Record = { 4: 'video', 5: 'invention', 6: 'room', + 8: 'room', + 9: 'roomunityasset', + 10: 'unityasset', } /** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */ @@ -100,6 +103,121 @@ function textField(body: Record, ...names: string[]): string | return undefined } +/** Workers requires quoted disposition parameters; Mono emits valid bare tokens. */ +function normalizeMultipartHeaders(contentType: string): TransformStream { + 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({ + 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() .use( '*', @@ -140,6 +258,46 @@ const app = new Hono() // (the `/` part the `cdn` worker serves back) the client // references it by. Also accepts a name-only post (no binary) that just echoes // back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`. + .post( + '/upload/unityassets', + describeRoute({ + tags: ['Upload'], + summary: 'Upload Studio source files', + security: AUTHED, + description: + 'Repeated multipart file parts, returned in request order with filename and SHA-256.', + }), + async (c) => { + const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) + if (id === null) return c.body(null, 401) + const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES) + const parsed = await readUploadForm(c.req.raw, limit) + if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status) + const form = parsed.form + const files = Array.from(form.values()).filter( + (value): value is File => value instanceof File + ) + if (files.length === 0) return c.json({ error: 'No source files supplied.' }, 400) + if (files.reduce((size, file) => size + file.size, 0) > limit) + return c.json({ error: 'Source files exceed the upload limit.' }, 413) + const uploaded = [] + for (const file of files) { + const bytes = await file.arrayBuffer() + const hash = await crypto.subtle.digest('SHA-256', bytes) + // SDK XYGEAHCXHVR: unsigned little-endian SHA-256, base36 padded to + // 50 digits, then the first 40 characters. Its source manifest already + // contains this name before upload; random names cannot round-trip. + const name = new Uint8Array(hash) + .reduceRight((n, byte) => n * 256n + BigInt(byte), 0n) + .toString(36) + .padStart(50, '0') + .slice(0, 40) + await c.env.CDN_ASSETS.put(`unityasset/${name}`, bytes, { sha256: hash }) + uploaded.push({ Filename: name, Hash: btoa(String.fromCharCode(...new Uint8Array(hash))) }) + } + return c.json(uploaded) + } + ) .post( '/upload', describeRoute({ @@ -173,16 +331,18 @@ const app = new Hono() const id = await validateAndGetAccountId(c.req.raw, secret) if (id === null) return c.body(null, 401) - const body = await c.req.parseBody().catch(() => ({}) as Record) + const roles = await validateAndGetRoles(c.req.raw, secret) + const isStaff = roles?.some((role) => STAFF_ROLES.has(role)) ?? false + const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES) + const parsed = await readUploadForm(c.req.raw, isStaff ? Number.MAX_SAFE_INTEGER : limit) + if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status) + const body = Object.fromEntries(parsed.form.entries()) // The binary part is identified by being a file (filename/content-type), // not by its field name — matching the reference's part detection. const file = Object.values(body).find((v): v is File => v instanceof File) if (file) { - const roles = await validateAndGetRoles(c.req.raw, secret) - const isStaff = roles?.some((role) => STAFF_ROLES.has(role)) ?? false - const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES) if (!isStaff && file.size > limit) { return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413) } @@ -208,6 +368,13 @@ const app = new Hono() // worker answer one from a HEAD instead of downloading the blob to digest it. sha256: await crypto.subtle.digest('SHA-256', bytes), }) + if (c.req.header('user-agent')?.startsWith('RRStudio')) { + const hash = await crypto.subtle.digest('SHA-256', bytes) + return c.json({ + Filename: filename, + Hash: btoa(String.fromCharCode(...new Uint8Array(hash))), + }) + } return c.json({ filename }) } diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index c82729a..d36dab5 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -235,7 +235,7 @@ it('GET /openapi.json documents every route', async () => { Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) ) ) - expect([...documented].sort()).toEqual(['GET /', 'POST /upload']) + expect([...documented].sort()).toEqual(['GET /', 'POST /upload', 'POST /upload/unityassets']) // Every operation carries a summary — a path present but undescribed is not // documentation. @@ -246,3 +246,167 @@ it('GET /openapi.json documents every route', async () => { // Every schema inlines: a `$ref` here would be a dangling reference (see openapi.ts). expect(JSON.stringify(spec).includes('"$ref"')).toBe(false) }) + +it('uploads Studio source files in order with verified hashes and a manifest under its own prefix', async () => { + const headers = { ...(await bearer()), 'User-Agent': 'RRStudio' } + const body = new FormData() + body.append('file', new File(['first'], 'a.txt')) + body.append('file', new File(['second'], 'b.txt')) + const res = await SELF.fetch(`${ORIGIN}/upload/unityassets`, { method: 'POST', headers, body }) + expect(res.status).toBe(200) + const files = (await res.json()) as Array<{ Filename: string; Hash: string }> + expect(files).toHaveLength(2) + for (const [i, file] of files.entries()) { + expect(file.Filename).toMatch(/^[0-9a-z]{40}$/) + const stored = await env.CDN_ASSETS.get(`unityasset/${file.Filename}`) + expect(await stored!.text()).toBe(i === 0 ? 'first' : 'second') + const hash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(i === 0 ? 'first' : 'second') + ) + expect(file.Hash).toBe(btoa(String.fromCharCode(...new Uint8Array(hash)))) + } + const manifest = new FormData() + manifest.append('fileType', '9') + manifest.append( + 'file', + new File(['{"Version":3,"Files":[]}'], 'manifest.json', { type: 'application/json' }) + ) + const uploaded = await SELF.fetch(`${ORIGIN}/upload`, { method: 'POST', headers, body: manifest }) + expect(uploaded.status).toBe(200) + const source = (await uploaded.json()) as { Filename: string; Hash: string } + expect(source.Hash).toBeTruthy() + expect(await (await env.CDN_ASSETS.get(`roomunityasset/${source.Filename}`))!.text()).toBe( + '{"Version":3,"Files":[]}' + ) + const unauthenticated = new FormData() + unauthenticated.append('file', new File(['x'], 'x.txt')) + expect( + (await SELF.fetch(`${ORIGIN}/upload/unityassets`, { method: 'POST', body: unauthenticated })) + .status + ).toBe(401) +}) + +it('accepts the Mono multipart wire format without changing binary file bytes', async () => { + const boundary = 'mono-studio-boundary' + const payload = new Uint8Array([0, 255, 128, 13, 10, 0, 200]) + const parts: Array = [] + 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>() + 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>() + 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 + } +}) diff --git a/apps/studio/README.md b/apps/studio/README.md index d2d2771..e85004e 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -3,18 +3,18 @@ The RecFlare Studio host (`https://studio.`). Device login stays on Auth, `GET /account/me` stays on Accounts, and the approval -page is WWW `/device`. This worker answers the editor's build list, stores a -locally built Windows and Android bundle pair, and answers the co-owner -presence list. It does not serve the bundle bytes. A stored build is loaded from -Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`) -and from the bucket at `/room/{filename}`. +page is WWW `/device`. This worker queues cloud builds, accepts completed bundles, +and serves build status and co-owner presence. Rooms exposes playable bundles in +`CurrentSave.UnitySubAssets`; their bytes are served by the CDN at `/room/{filename}`. +The Rooms `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset` route separately +returns the editable source manifest and available build metadata. -| Method | Path | Purpose | -| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. | -| `GET` | `/cloud-builds/get-build-count-for-user` | Authenticated user's Studio build totals for the current UTC-hour rate-limit window. | -| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished cloud build on the subroom's latest save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. | -| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. | +| Method | Path | Purpose | +| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. | +| `GET` | `/cloud-builds/get-build-count-for-user` | Authenticated user's Studio build totals for the current UTC-hour rate-limit window. | +| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished build on the subroom's current published save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. | +| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. | ## Development @@ -49,3 +49,36 @@ the rooms migrations too. Deploying does not apply either. just migrate -F studio # remote just migrate -F studio -- --local # dev db ``` + +## Editor build limits + +`GET /cloud-builds/get-rate-limit-config` returns the editor's +`CloudBuildRateLimitConfigDTO` directly: `buildThrottleIntervalHours` (1), +`warnAfterBuildsPerInterval` (90), and `maxBuildsPerInterval` (100). These names +matter: unknown keys leave the DTO's maximum at zero and block even a first build. +The authenticated `get-build-count-for-user` route provides the user's counts. + +## Queued Studio builds + +`POST /cloud-builds/create` accepts the SDK's room, subroom, save, auto-publish, +and optional target fields. It requires Studio authorization and room management +rights, validates the uploaded source manifest, and returns a numeric string +build ID with a null completion time. Repeated requests for an in-progress save +reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status; +`PUT /cloud-builds/cancel` cancels a queued or running job. + +Apply migration `0003_studio_cloud_build_job.sql`. Configure a private +`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with +the matching credential. Without a key, creation returns an explicit unavailable +response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and +`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to +the enabled targets. + +The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish` +routes lease jobs to that worker. Only validated UnityFS artifacts for the +allocated asset IDs and requested platforms are accepted. SDK bundle version 5 +is retained. Completion links the submitted save; auto-publish does not replace +a newer draft. Failed, cancelled, or expired jobs never become successful builds. +Claims include a signed, short-lived SDK validation token with no account subject +or roles. Deploy the paired API support: only bulk component-text validation +accepts this credential. It cannot authenticate as an account or publish a room. diff --git a/apps/studio/migrations/0003_studio_cloud_build_job.sql b/apps/studio/migrations/0003_studio_cloud_build_job.sql new file mode 100644 index 0000000..0572428 --- /dev/null +++ b/apps/studio/migrations/0003_studio_cloud_build_job.sql @@ -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); diff --git a/apps/studio/src/cloud-builds.ts b/apps/studio/src/cloud-builds.ts new file mode 100644 index 0000000..f9300ef --- /dev/null +++ b/apps/studio/src/cloud-builds.ts @@ -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 + 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() +} +const app = new Hono() + .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() + } 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() + 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=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=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() + 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() + 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() + 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 diff --git a/apps/studio/src/context.ts b/apps/studio/src/context.ts index 6d7e60a..0c2b8fd 100644 --- a/apps/studio/src/context.ts +++ b/apps/studio/src/context.ts @@ -17,6 +17,10 @@ export type Env = SharedHonoEnv & { // Shared `recflare-cdn` bucket. Local Studio builds land under // `room/studio//`. The same bucket name the rooms and cdn workers bind. CDN_ASSETS: R2Bucket + /** Private pull-worker credential; leave unset to disable cloud build dispatch. */ + CLOUD_BUILD_KEY?: string + CLOUD_BUILD_TARGETS?: string + CLOUD_BUILD_TEMPLATE_VERSION?: string } /** Variables can be extended */ diff --git a/apps/studio/src/local-builds.ts b/apps/studio/src/local-builds.ts index 0f6c74c..6d0aada 100644 --- a/apps/studio/src/local-builds.ts +++ b/apps/studio/src/local-builds.ts @@ -19,6 +19,7 @@ export interface CloudBuildRow { error: string | null unityAssetId: string createdByAccountId: number + serverDataFileName?: string | null } export type BundlePlatform = StudioBundlePlatform @@ -58,10 +59,14 @@ function toCloudBuild(row: BuildListRow): CloudBuildRow { return { cloudBuildId: row.cloud_build_id, startedAt: row.started_at, - completedAt: row.completed_at, + completedAt: row.completed_at || null, error: row.error, unityAssetId: row.unity_asset_id, createdByAccountId: row.created_by_account_id, + serverDataFileName: + row.completed_at && !row.error && /^\d+$/.test(row.cloud_build_id) + ? `studio-builds/${row.cloud_build_id}/server-data.json` + : null, } } diff --git a/apps/studio/src/studio.app.ts b/apps/studio/src/studio.app.ts index e5e6393..7663567 100644 --- a/apps/studio/src/studio.app.ts +++ b/apps/studio/src/studio.app.ts @@ -10,6 +10,7 @@ import { import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' +import cloudBuilds from './cloud-builds' import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' import { parseMultipart } from './multipart' @@ -160,10 +161,14 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) + .route('/', cloudBuilds) .get('/', async (c) => { return c.text('hello, world!') }) + // There is no circuit recommendation corpus on a fresh deployment. An empty + // list is the SDK's supported no-recommendations state, not a failed lookup. + .get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([])) // The editor lists a room's cloud builds with // GET /cloud-builds/for-room?roomId=&subRoomId=&skip=&take= on this host. @@ -182,16 +187,13 @@ const app = new Hono() // Local Windows + Android bundles from the editor button under Upload. .post('/cloud-builds/from-editor', (c) => postLocalCloudBuild(c)) - // Studio asks how often it may request cloud builds. Stubbed: this server - // never queues a real cloud build (every build is local, see from-editor), - // so the numbers are permissive placeholders. The field names are an - // ASSUMPTION — what the editor reads from this response has not been - // observed; adjust once it has. + // CloudBuildRateLimitConfigDTO reads these three fields directly (no Result + // envelope). Missing MaxBuildsPerInterval becomes zero and blocks the first build. .get('/cloud-builds/get-rate-limit-config', (c) => c.json({ - maxBuildsPerHour: 100, - maxConcurrentBuilds: 10, - cooldownSeconds: 0, + buildThrottleIntervalHours: 1, + warnAfterBuildsPerInterval: 90, + maxBuildsPerInterval: 100, }) ) diff --git a/apps/studio/src/test/integration/api.test.ts b/apps/studio/src/test/integration/api.test.ts index e71c60d..10a41a0 100644 --- a/apps/studio/src/test/integration/api.test.ts +++ b/apps/studio/src/test/integration/api.test.ts @@ -19,6 +19,12 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' const TEST_SECRET = 'test-signing-key' +it('returns an empty recommendation list when no circuit corpus is installed', async () => { + const response = await SELF.fetch(`${ORIGIN}/commonQuery/rrstudio/chiprecommendations`) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([]) +}) + function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) let binary = '' @@ -113,13 +119,13 @@ it('lists no cloud builds for a room', async () => { expect(await res.json()).toEqual({ results: [], totalResults: 0 }) }) -it('serves a stubbed cloud build rate limit config', async () => { +it('serves the Studio rate-limit DTO without blocking a first build', async () => { const res = await SELF.fetch('https://example.com/cloud-builds/get-rate-limit-config') expect(res.status).toBe(200) expect(await res.json()).toEqual({ - maxBuildsPerHour: 100, - maxConcurrentBuilds: 10, - cooldownSeconds: 0, + buildThrottleIntervalHours: 1, + warnAfterBuildsPerInterval: 90, + maxBuildsPerInterval: 100, }) }) diff --git a/apps/studio/src/test/integration/cloud-builds.test.ts b/apps/studio/src/test/integration/cloud-builds.test.ts new file mode 100644 index 0000000..bb6c214 --- /dev/null +++ b/apps/studio/src/test/integration/cloud-builds.test.ts @@ -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>, 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>) { + 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() + } +}) diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index b568a80..e9c23da 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -845,6 +845,94 @@ export async function cloneRoom( return cloned } +/** Create an empty Studio room, reserving its id and name in one write. */ +export async function createStudioRoom( + db: D1Database, + name: string, + accountId: number, + ugcVersion: 1 | 2 +): Promise { + const room: Room = { + RoomId: 0, + Name: name, + Description: '', + ImageName: '', + CreatorAccountId: accountId, + Accessibility: Accessibility.Private, + State: 0, + PublishState: 0, + WarningMask: 0, + CustomWarning: null, + SupportsScreens: true, + SupportsWalkVR: true, + SupportsTeleportVR: true, + SupportsVRLow: true, + SupportsQuest2: true, + SupportsMobile: true, + SupportsJuniors: true, + SupportsLevelVoting: false, + IsRRO: false, + IsRecRoomApproved: false, + IsDorm: false, + IsPlacePlay: false, + IsDeveloperOwned: false, + IsJuniorCreated: false, + ExcludeFromLists: false, + ExcludeFromSearch: false, + CloningAllowed: false, + MinLevel: 0, + AgeRating: 0, + CreatedAt: new Date().toISOString(), + PublishedAt: null, + BecameRRStudioRoomAt: null, + Stats: { ...ZERO_STATS }, + UgcVersion: ugcVersion, + PersistenceVersion: 0, + UgcSubVersion: null, + MinUgcSubVersion: null, + MaxPlayers: 4, + MaxPlayerCalculationMode: 0, + DisableMicAutoMute: false, + DisableRoomComments: false, + EncryptVoiceChat: false, + ToxmodEnabled: false, + LoadScreenLocked: false, + AutoLocalizeRoom: false, + LocalizationContext: {}, + RankedEntityId: '', + Roles: [ + { + AccountId: accountId, + Role: Role.Creator, + LastChangedByAccountId: null, + InvitedRole: Role.None, + }, + ], + Tags: [], + SubRooms: [], + } + const inserted = await db + .prepare( + `INSERT INTO room (data) + SELECT json_set(?1, '$.RoomId', COALESCE(MAX(room_id), 0) + 1) FROM room + HAVING NOT EXISTS (SELECT 1 FROM room WHERE name_lower = lower(?2)) + RETURNING data` + ) + .bind(serializeRoom(room), name) + .first<{ data: string }>() + if (!inserted) return null + const created = JSON.parse(inserted.data) as Room + // Studio's picker and empty-save loader support these modern Maker Room scenes, + // not the legacy sandbox scene used by generic game subroom creation. + const scene = + ugcVersion === 2 + ? '5d4e40d8-f289-4295-a6e1-4f907835007d' + : 'a75f7547-79eb-47c6-8986-6767abcb4f92' + return ( + (await createSubRoom(db, Number(created.RoomId), accountId, 'Home', scene))?.room ?? created + ) +} + /** Set a room's Description in place (the caller is responsible for the owner check). */ export async function setRoomDescription( db: D1Database, @@ -1439,9 +1527,13 @@ export interface SaveSubRoomDataInput { */ description?: string persistenceVersion?: number + omVersion?: number + ugcSubVersion?: number inventionUsage?: string /** Optional baked-asset id; emitted on the save only when present. */ unityAssetId?: string + /** Uploaded JSON authoring manifest, distinct from a playable baked bundle. */ + unitySource?: { Filename: string; Hash: string | null } /** * The client's `AutoPublish`. True publishes the save outright (the author wants it * live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and @@ -1476,9 +1568,11 @@ export function subRoomDataBlob(sub: SubRoom | undefined | null): string { /** Fields that vary between a real save and one reconstructed from the legacy shape. */ interface BuildSaveInput { subRoomId: unknown - dataBlob: string + dataBlob: string | null dataBlobHash: string | null persistenceVersion: number + omVersion?: number + ugcSubVersion?: number savedByAccountId: unknown description: string createdAt: string @@ -1511,8 +1605,8 @@ function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave { DataBlobHash: input.dataBlobHash, ReferencedUnityAssetIds: [], PersistenceVersion: input.persistenceVersion, - OMVersion: 0, - UgcSubVersion: 0, + OMVersion: input.omVersion ?? 0, + UgcSubVersion: input.ugcSubVersion ?? 0, SavedByAccountId: input.savedByAccountId, SavedOnPlatform: 0, SavedOnDeviceClass: 0, @@ -1745,25 +1839,34 @@ export async function saveSubRoomData( : undefined) const priorVersion = previous?.PersistenceVersion const priorBlob = previous?.DataBlob - const save = await insertSubRoomSave( - db, + const saveData = buildSubRoomSave({ subRoomId, - buildSubRoomSave({ - subRoomId, - // A save that carries no new blob (e.g. a description-only save) keeps the one - // the subroom already loads from. - dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''), - dataBlobHash: input.subRoomDataHash ?? null, - persistenceVersion: - input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0), - savedByAccountId: accountId, - // The save comment — empty string, not null, when the save carries none (the - // reference's `roomDesc ?? ""`). - description: input.description ?? '', - createdAt: new Date().toISOString(), - unityAssetId: input.unityAssetId, - }) - ) + // A save that carries no new blob (e.g. a description-only save) keeps the one + // the subroom already loads from. + dataBlob: + input.subRoomDataFilename ?? + (typeof priorBlob === 'string' || priorBlob === null ? priorBlob : ''), + dataBlobHash: input.subRoomDataHash ?? null, + persistenceVersion: + input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0), + omVersion: + input.omVersion ?? (typeof previous?.OMVersion === 'number' ? previous.OMVersion : 0), + ugcSubVersion: + input.ugcSubVersion ?? + (typeof previous?.UgcSubVersion === 'number' ? previous.UgcSubVersion : 0), + savedByAccountId: accountId, + // The save comment — empty string, not null, when the save carries none (the + // reference's `roomDesc ?? ""`). + description: input.description ?? '', + createdAt: new Date().toISOString(), + unityAssetId: input.unityAssetId, + }) + if (input.unitySource) { + saveData.StudioSourceAsset = input.unitySource + saveData.SavedOnPlatform = null + saveData.SavedOnDeviceClass = null + } + const save = await insertSubRoomSave(db, subRoomId, saveData) const saveId = Number(save.SubRoomDataSaveId) sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion @@ -1951,7 +2054,8 @@ export async function createSubRoom( db: D1Database, roomId: number, accountId: number, - name: string + name: string, + initialScene?: string ): Promise<{ room: Room; subRoom: SubRoom } | null> { const room = await getRoomById(db, roomId) if (!room) return null @@ -1959,14 +2063,29 @@ export async function createSubRoom( const subRoom = await insertSubRoom(db, roomId, { Name: name, CreatorAccountId: accountId, - UnitySceneId: await baseSubRoomScene(db, roomId), + UnitySceneId: initialScene ?? (await baseSubRoomScene(db, roomId)), MaxPlayers: 4, Accessibility: Accessibility.Unlisted, IsSandbox: true, LastModeratedSaveModerationState: 0, ShouldAutoStageSaves: true, - // Nothing saved yet — the first room save mints one and points current_save_id - // at it. Until then the subroom reads with `CurrentSave: null`. + // Rooms 2 validates CurrentSave.OMVersion even when the scene uses its built-in + // empty template. No blob or uploaded Studio bundle is needed for that template. + ...(room.UgcVersion === 2 + ? { + CurrentSave: buildSubRoomSave({ + subRoomId: 0, + dataBlob: null, + dataBlobHash: null, + persistenceVersion: 0, + // 20260323.14's built-in object-model template format (0xC6). + omVersion: 198, + savedByAccountId: accountId, + description: '', + createdAt: new Date().toISOString(), + }), + } + : {}), }) // Refresh the hydrated SubRooms so the returned room includes the one just inserted. await attachSubRooms(db, [room]) @@ -2706,6 +2825,20 @@ async function hydrateRooms( rooms: Room[], stats?: Map ): Promise { + 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 decoder and discards the entire owned-room list. + if (room.PublishedAt === '') room.PublishedAt = null + } await Promise.all([ attachSubRooms(db, rooms), attachTags(db, rooms), diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index 66185c3..1012c4e 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -5,6 +5,8 @@ export { validateAndGetRoles, validateAndGetVersion, generateToken, + generateStudioBuildValidationToken, + validateStudioBuildValidationToken, generatePhotonAuthToken, TOKEN_TTL_SECONDS, } from './jwt' diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index c183c2e..06b7dd3 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -18,6 +18,43 @@ import { GAME_VERSION, gameBuild } from '@repo/domain' // last a day so the client doesn't have to refresh it. export const TOKEN_TTL_SECONDS = 86400 +/** A build host can check component text, but cannot act as an account. */ +export async function generateStudioBuildValidationToken( + buildId: string, + secret: string +): Promise { + 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 { + const header = request.headers.get('Authorization') ?? '' + if (!header.startsWith('Bearer ')) return false + try { + const payload = await verify(header.slice(7), secret, 'HS256') + return ( + payload.sub === undefined && + payload.aud === 'studio-build-validation' && + payload.scope === 'studio.build.validate' && + typeof payload.buildId === 'string' && + /^[1-9]\d*$/.test(payload.buildId) + ) + } catch { + return false + } +} + /** * Validate an HS256 token and return its `sub` (account id) claim, or `null` when * the token is malformed, has a bad signature, or is expired/not-yet-valid.