[studio] beta studio (#114)

* rrs stuff

* studio service yay

* rrs whitelist

* moree studiooooooo fellowwww

* rec room studio room loading yay

* fixes yay

* fix 2 because idk  yayuh

* Update cdn.app.ts

* ccontent length fix?

* Preserve content-length and upgrade metadata

Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable.

* fix x524234

* shirt thing?

* [auth] remove grant table as we already have audit_log for now

* rename table to generic device_grant

* cleanup/lint

---------

Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
Crasho
2026-10-05 19:39:22 +01:00
committed by GitHub
parent 0900bcac25
commit 26270e9c25
52 changed files with 18231 additions and 103 deletions
+27
View File
@@ -0,0 +1,27 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/**
* Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value with
* `await env.JWT_SECRET.get()`; every worker binds the same store, so tokens signed by
* `auth` verify here.
*/
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms`
// worker owns that schema) and who is standing where lives in `presence`.
// This worker migrates its own cloud-build tables (`studio_cloud_build`,
// `studio_unity_asset_file`) and only reads the room tables.
DB: D1Database
// Shared `recflare-cdn` bucket. Local Studio builds land under
// `studio-room-bundles/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export interface App extends HonoApp {
Bindings: Env
Variables: Variables
}
+211
View File
@@ -0,0 +1,211 @@
import {
getSubRoom,
markRoomAsRecRoomStudio,
publicStudioBundleFilename,
setSubRoomSaveUnityAssetId,
} from '@repo/domain'
/** Mirrors migrations/0001_studio_cloud_build.sql. Tests apply this; deploy uses the file. */
export const STUDIO_CLOUD_BUILD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS studio_cloud_build (
cloud_build_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
sub_room_id INTEGER NOT NULL,
sub_room_data_save_id INTEGER NOT NULL,
unity_asset_id TEXT NOT NULL,
created_by_account_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
error TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
ON studio_cloud_build (room_id, sub_room_id, started_at)`,
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
byte_length INTEGER NOT NULL,
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
)`,
]
export interface CloudBuildRow {
cloudBuildId: string
startedAt: string
completedAt: string | null
error: string | null
unityAssetId: string
createdByAccountId: number
}
export type BundlePlatform = 'windows' | 'android'
export type BundleKind = 'main' | 'stripped'
export interface LocalBundleFile {
platform: BundlePlatform
kind: BundleKind
filename: string
bytes: ArrayBuffer
}
export interface StoredLocalBuild extends CloudBuildRow {
subRoomDataSaveId: number
}
interface BuildListRow {
cloud_build_id: string
started_at: string
completed_at: string | null
error: string | null
unity_asset_id: string
created_by_account_id: number
}
function toCloudBuild(row: BuildListRow): CloudBuildRow {
return {
cloudBuildId: row.cloud_build_id,
startedAt: row.started_at,
completedAt: row.completed_at,
error: row.error,
unityAssetId: row.unity_asset_id,
createdByAccountId: row.created_by_account_id,
}
}
/** Page of cloud builds for one subroom, newest first. `total` counts the whole subroom. */
export async function listCloudBuilds(
db: D1Database,
roomId: number,
subRoomId: number,
skip: number,
take: number
): Promise<{ results: CloudBuildRow[]; totalResults: number }> {
const total = await db
.prepare(
`SELECT COUNT(*) AS n FROM studio_cloud_build
WHERE room_id = ?1 AND sub_room_id = ?2`
)
.bind(roomId, subRoomId)
.first<{ n: number | string }>()
const { results } = await db
.prepare(
`SELECT cloud_build_id, started_at, completed_at, error, unity_asset_id, created_by_account_id
FROM studio_cloud_build
WHERE room_id = ?1 AND sub_room_id = ?2
ORDER BY started_at DESC, cloud_build_id DESC
LIMIT ?3 OFFSET ?4`
)
.bind(roomId, subRoomId, take, skip)
.all<BuildListRow>()
return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) }
}
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Store a PC-built Windows + Android pair as a cloud build that is already finished.
* The bytes go to R2. The build row points at the subroom's current save, that save's
* `UnityAssetId` is set to the new asset, and the room is marked as a Studio room the
* first time (`BecameRRStudioRoomAt`).
*
* Returns null when the subroom has no current save — the caller uploaded bundles
* before Upload created one.
*/
export async function storeLocalCloudBuild(
db: D1Database,
bucket: R2Bucket,
roomId: number,
subRoomId: number,
accountId: number,
files: LocalBundleFile[]
): Promise<StoredLocalBuild | null> {
const sub = await getSubRoom(db, roomId, subRoomId)
if (!sub) return null
const save = sub.CurrentSave
const saveId =
save && typeof save === 'object'
? Number((save as { SubRoomDataSaveId?: unknown }).SubRoomDataSaveId)
: NaN
if (!Number.isInteger(saveId) || saveId <= 0) return null
const now = new Date().toISOString()
const cloudBuildId = crypto.randomUUID()
const unityAssetId = crypto.randomUUID()
const stored = await Promise.all(
files.map(async (file) => {
const sha256 = await sha256Hex(file.bytes)
// The download route looks a bundle up by filename alone, so the stored
// name is this build's, not the uploaded basename two rooms can share.
const filename = publicStudioBundleFilename(unityAssetId, file.platform, file.kind)
const r2Key = `studio-room-bundles/${unityAssetId}/${file.platform}/${file.kind}/${filename}`
await bucket.put(r2Key, file.bytes)
return { ...file, filename, sha256, r2Key }
})
)
const discardObjects = () => Promise.all(stored.map((file) => bucket.delete(file.r2Key)))
try {
await db.batch([
...stored.map((file) =>
db
.prepare(
`INSERT INTO studio_unity_asset_file
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
)
.bind(
unityAssetId,
file.platform,
file.kind,
file.filename,
file.sha256,
file.bytes.byteLength,
file.r2Key
)
),
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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, NULL)`
)
.bind(cloudBuildId, roomId, subRoomId, saveId, unityAssetId, accountId, now),
])
} catch (err) {
await discardObjects()
throw err
}
const linked = await setSubRoomSaveUnityAssetId(db, subRoomId, saveId, unityAssetId)
if (!linked) {
await db
.prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1')
.bind(cloudBuildId)
.run()
await db
.prepare('DELETE FROM studio_unity_asset_file WHERE unity_asset_id = ?1')
.bind(unityAssetId)
.run()
await discardObjects()
return null
}
await markRoomAsRecRoomStudio(db, roomId, now)
return {
cloudBuildId,
startedAt: now,
completedAt: now,
error: null,
unityAssetId,
createdByAccountId: accountId,
subRoomDataSaveId: saveId,
}
}
+212
View File
@@ -0,0 +1,212 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
canManageRoomById,
getPlayerIdsInRoomSubRoom,
getRoomCoOwnerIds,
getSubRoom,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { listCloudBuilds, storeLocalCloudBuild } from './local-builds'
import type { Context } from 'hono'
import type { App } from './context'
/**
* A query id the editor already validated as a positive integer. Anything else is
* treated as "no such room" rather than an error: a non-2xx makes Studio throw
* before it can show the empty co-owner list.
*/
function positiveId(raw: string | undefined): number | null {
if (raw == null || !/^[1-9]\d*$/.test(raw)) return null
const id = Number(raw)
return Number.isSafeInteger(id) ? id : null
}
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.'
const NOT_AUTHORIZED =
'You do not have authorization to upload RecFlare Studio content at this time.'
/** `skip` >= 0 and `take` > 0, or the editor gets an empty page instead of an error. */
function cloudBuildPage(c: Context<App>): {
roomId: number
subRoomId: number
skip: number
take: number
} | null {
const roomId = positiveId(c.req.query('roomId'))
const subRoomId = positiveId(c.req.query('subRoomId'))
const skipRaw = c.req.query('skip')
const takeRaw = c.req.query('take')
if (roomId == null || subRoomId == null || skipRaw == null || takeRaw == null) return null
if (!/^\d+$/.test(skipRaw) || !/^[1-9]\d*$/.test(takeRaw)) return null
const skip = Number(skipRaw)
const take = Number(takeRaw)
if (!Number.isSafeInteger(skip) || !Number.isSafeInteger(take)) return null
return { roomId, subRoomId, skip, take: Math.min(take, 100) }
}
function rejected(c: Context<App>, status: 400 | 403 | 404, error: string) {
return c.json({ success: false, error, value: null }, status)
}
function formText(form: Record<string, unknown>, name: string): string | undefined {
const value = form[name]
return typeof value === 'string' ? value : undefined
}
/** Basename, then anything outside the bundle filename alphabet becomes `_`. */
function bundleFilename(name: string): string {
const base = name.split(/[/\\]/).pop() ?? ''
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '_')
return cleaned.length > 0 ? cleaned : 'bundle.assetbundle'
}
async function bundlePart(
form: Record<string, unknown>,
field: string,
platform: 'windows' | 'android',
kind: 'main' | 'stripped'
) {
const value = form[field]
if (!(value instanceof File) || value.size === 0) return null
return {
platform,
kind,
filename: bundleFilename(value.name),
bytes: await value.arrayBuffer(),
}
}
/**
* The editor button builds Windows, then Android, on the creator's PC and posts both
* bundles here. This is not `cloud-builds/create` (that one only names a save and
* waits for a server build). The pair is stored as a cloud build that is already
* finished, on the subroom's current save.
*/
async function postLocalCloudBuild(c: Context<App>) {
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 || roles == null) return c.body(null, 401)
// Same claim the editor treats as Full Studio access. Developer does not confer it.
if (!roles.includes('betastudio')) return rejected(c, 403, NOT_AUTHORIZED)
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const roomId = positiveId(formText(form, 'roomId'))
if (roomId == null) return rejected(c, 404, 'No such room.')
const allowed = await canManageRoomById(c.env.DB, roomId, accountId)
if (allowed == null) return rejected(c, 404, 'No such room.')
if (!allowed) return rejected(c, 403, 'This account cannot upload a build for that room.')
const subRoomId = positiveId(formText(form, 'subRoomId'))
if (subRoomId == null) return rejected(c, 404, 'No such subroom.')
const sub = await getSubRoom(c.env.DB, roomId, subRoomId)
if (!sub) return rejected(c, 404, 'No such subroom.')
const windows = await bundlePart(form, 'windows', 'windows', 'main')
const android = await bundlePart(form, 'android', 'android', 'main')
if (!windows || !android) return rejected(c, 400, BUNDLE_REQUIRED)
const files = [windows, android]
const windowsStripped = await bundlePart(form, 'windowsStripped', 'windows', 'stripped')
const androidStripped = await bundlePart(form, 'androidStripped', 'android', 'stripped')
if (windowsStripped) files.push(windowsStripped)
if (androidStripped) files.push(androidStripped)
const stored = await storeLocalCloudBuild(
c.env.DB,
c.env.CDN_ASSETS,
roomId,
subRoomId,
accountId,
files
)
if (!stored) return rejected(c, 400, UPLOAD_FIRST)
return c.json({
success: true,
error: null,
value: {
cloudBuildId: stored.cloudBuildId,
startedAt: stored.startedAt,
completedAt: stored.completedAt,
error: stored.error,
unityAssetId: stored.unityAssetId,
createdByAccountId: stored.createdByAccountId,
},
})
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
.get('/', async (c) => {
return c.text('hello, world!')
})
// The editor lists a room's cloud builds with
// GET /cloud-builds/for-room?roomId=&subRoomId=&skip=&take= on this host.
// Studio reads `results` and `totalResults` (camelCase, not a Result wrapper)
// and treats any non-2xx as failure. No bearer: the panel asks even when the
// sign-in state is unclear, and a 401 would blank the list as an error.
// A bad id, skip, or take is an empty page for the same reason.
.get('/cloud-builds/for-room', async (c) => {
const page = cloudBuildPage(c)
if (page == null) return c.json({ results: [], totalResults: 0 })
return c.json(
await listCloudBuilds(c.env.DB, page.roomId, page.subRoomId, page.skip, page.take)
)
})
// Local Windows + Android bundles from the editor button under Upload.
.post('/cloud-builds/from-editor', (c) => postLocalCloudBuild(c))
// Who of this room's co-owners is standing in this subroom right now. Studio
// shows them as "Co-owners in Room" and reads a Result<List<int>>: `success`
// must be true and `error` null, or the editor throws; `value` is those
// account ids (camelCase). An empty list is the "nobody here" state.
//
// Co-owners are the room's creator plus every Creator/CoOwner role. Presence
// has to name this roomId AND this subRoomId — a co-owner in another subroom,
// or in another room, is not in the list. Hosts and moderators are not
// co-owners, and a pending co-owner invite is not a role yet.
.get('/collaboration/owners-in-room', async (c) => {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId == null) return c.body(null, 401)
const roomId = positiveId(c.req.query('roomId'))
const subRoomId = positiveId(c.req.query('subRoomId'))
if (roomId == null || subRoomId == null) {
return c.json({ success: true, error: null, value: [] })
}
const coOwners = await getRoomCoOwnerIds(c.env.DB, roomId)
if (coOwners == null || coOwners.length === 0) {
return c.json({ success: true, error: null, value: [] })
}
const present = new Set(await getPlayerIdsInRoomSubRoom(c.env.DB, roomId, subRoomId))
return c.json({
success: true,
error: null,
value: coOwners.filter((id) => present.has(id)),
})
})
export default app
@@ -0,0 +1,427 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import { PRESENCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, setPresence, SUBROOM_SCHEMA_DDL } from '@repo/domain'
import { STUDIO_CLOUD_BUILD_SCHEMA_DDL } from '../../local-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: string, roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const payload: Record<string, unknown> = { sub, exp: now + 3600 }
if (roles != null) payload.role = roles
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(payload)
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
async function insertRoom(
roomId: number,
creatorAccountId: number,
roles: unknown[]
): Promise<void> {
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: roomId,
Name: `Room ${roomId}`,
CreatorAccountId: creatorAccountId,
IsDorm: false,
Accessibility: 1,
Roles: roles,
})
)
.run()
}
async function standIn(
accountId: number,
roomId: number,
subRoomId: number,
{ expired = false }: { expired?: boolean } = {}
): Promise<void> {
if (expired) {
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 800000 + accountId, roomId, subRoomId },
expiresAt: Math.floor(Date.now() / 1000) - 10,
})
)
.run()
return
}
await setPresence(env.DB, {
accountId,
roomInstance: { roomInstanceId: 800000 + accountId, roomId, subRoomId },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: 'test',
})
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
it('response with hello world', async () => {
const res = await SELF.fetch('https://example.com')
expect(res.status).toBe(200)
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
})
it('lists no cloud builds for a room', async () => {
const res = await SELF.fetch(
'https://example.com/cloud-builds/for-room?roomId=1&subRoomId=1&skip=0&take=20'
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ results: [], totalResults: 0 })
})
describe('GET /collaboration/owners-in-room', () => {
const roomId = 1106
const subRoomId = 22941
beforeAll(async () => {
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Creator 1, co-owner 7, another co-owner 11, moderator 8, pending invite 9.
await insertRoom(roomId, 1, [
{ AccountId: 1, Role: 255, InvitedRole: 0 },
{ AccountId: 7, Role: 30, InvitedRole: 0 },
{ AccountId: 11, Role: 30, InvitedRole: 0 },
{ AccountId: 13, Role: 30, InvitedRole: 0 },
{ AccountId: 8, Role: 20, InvitedRole: 0 },
{ AccountId: 9, Role: 0, InvitedRole: 30 },
])
await standIn(1, roomId, subRoomId)
await standIn(7, roomId, subRoomId)
await standIn(11, roomId, 22942)
await standIn(8, roomId, subRoomId)
await standIn(9, roomId, subRoomId)
await standIn(10, roomId, subRoomId)
await standIn(12, 9999, subRoomId)
await standIn(13, roomId, subRoomId, { expired: true })
})
it('requires a bearer token', async () => {
const res = await SELF.fetch(
`${ORIGIN}/collaboration/owners-in-room?roomId=${roomId}&subRoomId=${subRoomId}`
)
expect(res.status).toBe(401)
})
it('lists co-owners whose presence is in this room and subroom', async () => {
const res = await SELF.fetch(
`${ORIGIN}/collaboration/owners-in-room?roomId=${roomId}&subRoomId=${subRoomId}`,
{ headers: await bearer('42') }
)
expect(res.status).toBe(200)
// 1 (creator) and 7 (role 30) are in subroom 22941. 11 is a co-owner in the
// other subroom. 13 is a co-owner whose presence has expired. 8 is a
// moderator, 9 is only invited, 10 has no role.
expect(await res.json()).toEqual({ success: true, error: null, value: [1, 7] })
})
it('returns an empty list for an unknown room', async () => {
const res = await SELF.fetch(
`${ORIGIN}/collaboration/owners-in-room?roomId=404&subRoomId=${subRoomId}`,
{ headers: await bearer('42') }
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: null, value: [] })
})
it('returns an empty list when the query ids are missing', async () => {
const res = await SELF.fetch(`${ORIGIN}/collaboration/owners-in-room`, {
headers: await bearer('42'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: null, value: [] })
})
})
const LOCAL_ROOM = 7701
const LOCAL_SUB = 8801
const LOCAL_SAVE = 88001
const UNSAVED_SUB = 8802
const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.'
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const NOT_AUTHORIZED =
'You do not have authorization to upload RecFlare Studio content at this time.'
const STUDIO_ROLES = ['gameClient', 'screenshare', 'betastudio']
async function insertSubRoom(
roomId: number,
subRoomId: number,
saveId: number | null
): Promise<void> {
await env.DB.prepare(
`INSERT INTO subroom (sub_room_id, room_id, data, current_save_id, staged_save_id)
VALUES (?1, ?2, ?3, ?4, NULL)`
)
.bind(subRoomId, roomId, JSON.stringify({ Name: 'Home' }), saveId)
.run()
if (saveId == null) return
await env.DB.prepare(
`INSERT INTO subroom_save (sub_room_data_save_id, sub_room_id, data) VALUES (?1, ?2, ?3)`
)
.bind(saveId, subRoomId, JSON.stringify({ DataBlob: 'scene' }))
.run()
}
function bundleFile(name: string, bytes: number[]): File {
return new File([new Uint8Array(bytes)], name)
}
async function postBuild(
tokenSub: string | null,
fields: Record<string, string | File>,
roles: string[] = STUDIO_ROLES
): Promise<Response> {
const body = new FormData()
for (const [key, value] of Object.entries(fields)) body.append(key, value)
return SELF.fetch(`${ORIGIN}/cloud-builds/from-editor`, {
method: 'POST',
headers: tokenSub == null ? undefined : await bearer(tokenSub, roles),
body,
})
}
async function studioFlag(roomId: number): Promise<string | null> {
const row = await env.DB.prepare(
`SELECT json_extract(data, '$.BecameRRStudioRoomAt') AS at FROM room WHERE room_id = ?1`
)
.bind(roomId)
.first<{ at: string | null }>()
return row?.at ?? null
}
describe('local cloud builds', () => {
beforeAll(async () => {
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Creator 42, co-owner 77. Account 99 has no role.
await insertRoom(LOCAL_ROOM, 42, [{ AccountId: 77, Role: 30, InvitedRole: 0 }])
await insertSubRoom(LOCAL_ROOM, LOCAL_SUB, LOCAL_SAVE)
await insertSubRoom(LOCAL_ROOM, UNSAVED_SUB, null)
})
const both = {
roomId: String(LOCAL_ROOM),
subRoomId: String(LOCAL_SUB),
windows: bundleFile('win.assetbundle', [1, 2, 3, 4]),
android: bundleFile('quest bundle.assetbundle', [5, 6]),
}
it('requires a bearer token', async () => {
const res = await postBuild(null, both)
expect(res.status).toBe(401)
})
it('rejects an account without studio access', async () => {
const res = await postBuild('42', both, ['gameClient', 'screenshare'])
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: NOT_AUTHORIZED, value: null })
expect(await studioFlag(LOCAL_ROOM)).toBeNull()
})
it('rejects an unknown room', async () => {
const res = await postBuild('42', { ...both, roomId: '7699' })
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ success: false, error: 'No such room.', value: null })
})
it('rejects an account that does not own the room', async () => {
// 8809 is not a subroom. Ownership is checked before the subroom, so this stays 403.
const res = await postBuild('99', { ...both, subRoomId: '8809' })
expect(res.status).toBe(403)
expect(await res.json()).toEqual({
success: false,
error: 'This account cannot upload a build for that room.',
value: null,
})
})
it('rejects an unknown subroom', async () => {
const res = await postBuild('42', { ...both, subRoomId: '8809' })
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ success: false, error: 'No such subroom.', value: null })
})
it('rejects a build that is missing a platform', async () => {
const res = await postBuild('42', {
roomId: both.roomId,
subRoomId: both.subRoomId,
windows: both.windows,
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: BUNDLE_REQUIRED, value: null })
})
it('rejects an empty bundle', async () => {
const res = await postBuild('42', { ...both, windows: bundleFile('win.assetbundle', []) })
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: BUNDLE_REQUIRED, value: null })
})
it('rejects a subroom that has no save yet', async () => {
const res = await postBuild('42', { ...both, subRoomId: String(UNSAVED_SUB) })
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: UPLOAD_FIRST, value: null })
expect(await studioFlag(LOCAL_ROOM)).toBeNull()
})
it('stores both bundles on the latest save and lists the build', async () => {
expect(await studioFlag(LOCAL_ROOM)).toBeNull()
const created = await postBuild('42', {
...both,
windowsStripped: bundleFile('win.Stripped.assetbundle', [7, 8, 9]),
})
expect(created.status).toBe(200)
const first = (await created.json()) as {
success: boolean
error: null
value: {
cloudBuildId: string
startedAt: string
completedAt: string
error: null
unityAssetId: string
createdByAccountId: number
}
}
expect(first.success).toBe(true)
expect(first.error).toBeNull()
expect(first.value.createdByAccountId).toBe(42)
expect(first.value.completedAt).toBe(first.value.startedAt)
expect(first.value.error).toBeNull()
expect(await studioFlag(LOCAL_ROOM)).toBe(first.value.startedAt)
const save = await env.DB.prepare(
`SELECT json_extract(data, '$.UnityAssetId') AS id
FROM subroom_save WHERE sub_room_data_save_id = ?1`
)
.bind(LOCAL_SAVE)
.first<{ id: string }>()
expect(save?.id).toBe(first.value.unityAssetId)
const buildRow = await env.DB.prepare(
`SELECT sub_room_data_save_id, error, completed_at
FROM studio_cloud_build WHERE cloud_build_id = ?1`
)
.bind(first.value.cloudBuildId)
.first<{ sub_room_data_save_id: number; error: string | null; completed_at: string }>()
expect(buildRow).toEqual({
sub_room_data_save_id: LOCAL_SAVE,
error: null,
completed_at: first.value.startedAt,
})
const { results: files } = await env.DB.prepare(
`SELECT platform, kind, filename, byte_length, r2_key
FROM studio_unity_asset_file WHERE unity_asset_id = ?1
ORDER BY platform, kind`
)
.bind(first.value.unityAssetId)
.all<{
platform: string
kind: string
filename: string
byte_length: number
r2_key: string
}>()
const publicName = (platform: string, kind: string) =>
`${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle`
expect(
files.map((file) => [file.platform, file.kind, file.filename, file.byte_length])
).toEqual([
['android', 'main', publicName('android', 'main'), 2],
['windows', 'main', publicName('windows', 'main'), 4],
['windows', 'stripped', publicName('windows', 'stripped'), 3],
])
const win = files.find((file) => file.platform === 'windows' && file.kind === 'main')
const stored = await env.CDN_ASSETS.get(win!.r2_key)
expect(stored).not.toBeNull()
expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4])
const again = await postBuild('42', both)
expect(again.status).toBe(200)
const second = (await again.json()) as typeof first
expect(second.value.cloudBuildId).not.toBe(first.value.cloudBuildId)
expect(await studioFlag(LOCAL_ROOM)).toBe(first.value.startedAt)
const saveAfter = await env.DB.prepare(
`SELECT json_extract(data, '$.UnityAssetId') AS id
FROM subroom_save WHERE sub_room_data_save_id = ?1`
)
.bind(LOCAL_SAVE)
.first<{ id: string }>()
expect(saveAfter?.id).toBe(second.value.unityAssetId)
await env.DB.prepare(
`UPDATE studio_cloud_build SET started_at = '2000-01-01T00:00:00.000Z' WHERE cloud_build_id = ?1`
)
.bind(first.value.cloudBuildId)
.run()
const listed = await SELF.fetch(
`${ORIGIN}/cloud-builds/for-room?roomId=${LOCAL_ROOM}&subRoomId=${LOCAL_SUB}&skip=0&take=20`
)
expect(listed.status).toBe(200)
const page = (await listed.json()) as {
results: Array<{ cloudBuildId: string; startedAt: string; createdByAccountId: number }>
totalResults: number
}
expect(page.totalResults).toBe(2)
expect(page.results.map((row) => row.cloudBuildId)).toEqual([
second.value.cloudBuildId,
first.value.cloudBuildId,
])
expect(page.results[0]!.createdByAccountId).toBe(42)
expect(page.results[1]!.startedAt).toBe('2000-01-01T00:00:00.000Z')
const past = await SELF.fetch(
`${ORIGIN}/cloud-builds/for-room?roomId=${LOCAL_ROOM}&subRoomId=${LOCAL_SUB}&skip=20&take=20`
)
expect(await past.json()).toEqual({ results: [], totalResults: 2 })
const bad = await SELF.fetch(
`${ORIGIN}/cloud-builds/for-room?roomId=${LOCAL_ROOM}&subRoomId=${LOCAL_SUB}&skip=-1&take=20`
)
expect(await bad.json()).toEqual({ results: [], totalResults: 0 })
const coOwner = await postBuild('77', both)
expect(coOwner.status).toBe(200)
const third = (await coOwner.json()) as typeof first
expect(third.value.createdByAccountId).toBe(77)
expect(await studioFlag(LOCAL_ROOM)).toBe(first.value.startedAt)
})
})