[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
+24
View File
@@ -157,6 +157,17 @@ export interface Account {
* real: the website's claim page warns about it, and so does `grant-plus`.
*/
hasPlus?: boolean
/**
* Whether this account may upload from Rec Room Studio. The editor decides Full access
* by reading the token for a `role` claim of exactly `betastudio`, which `auth` stamps
* from this flag alone — `developer` does not imply it. Staff set and clear it from the
* website (`www` `/settings/recroomstudio`); an operator can too, via
* `runx admin grant-studio`. Absent/false means Limited.
*
* Like the other flags it reaches the editor on the NEXT token: a Studio session already
* open keeps its old access until it signs in again or refreshes.
*/
hasStudio?: boolean
}
interface AccountRow {
@@ -414,6 +425,19 @@ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<A
return parseAll(pages.flatMap((page) => page.results))
}
/**
* Every account flagged `hasStudio`, lowest id first — the website's staff list. A JSON
* scan rather than a generated column: the list is short and only staff read it.
*/
export async function listStudioAccounts(db: D1Database): Promise<Account[]> {
const { results } = await db
.prepare(
`SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
)
.all<AccountRow>()
return parseAll(results)
}
/**
* Merge `overrides` into the account row for `id` and persist it. Reads the
* current account (falling back to a synthesized default), applies the
+1
View File
@@ -7,6 +7,7 @@ export {
MatchmakingErrorCode,
} from './enums'
export * from './accounts-db'
export * from './studio-unity-assets'
export * from './audit-db'
export * from './clubs-db'
export * from './d1-binds'
+29
View File
@@ -238,6 +238,35 @@ export async function getPlayerIdsInRoom(
return results.map((r) => r.accountId)
}
/**
* Everyone standing in one subroom of a room right now, across every live instance of
* that subroom. The narrower cut of {@link getPlayerIdsInRoom}: a room has several
* subrooms, and a player in a different subroom of the same room is not here.
*
* `subRoomId` is not its own column — presence indexes the room and the instance — so
* the subroom is read out of the instance blob. Unexpired presence only, lobby
* (null-instance) rows excluded, ordered by account id.
*/
export async function getPlayerIdsInRoomSubRoom(
db: D1Database,
roomId: number,
subRoomId: number,
now = nowSeconds()
): Promise<number[]> {
const { results } = await db
.prepare(
`SELECT DISTINCT account_id AS accountId FROM presence
WHERE room_id = ?1
AND expires_at > ?2
AND room_instance_id IS NOT NULL
AND CAST(json_extract(data, '$.roomInstance.subRoomId') AS INTEGER) = ?3
ORDER BY account_id`
)
.bind(roomId, now, subRoomId)
.all<{ accountId: number }>()
return results.map((r) => r.accountId)
}
/**
* Everyone online right now, anywhere — the same population {@link countOnlinePlayers}
* counts, as ids. Unexpired presence only, one row per account, lobby (null-instance)
+132 -6
View File
@@ -20,6 +20,13 @@
import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
import {
bakedStudioUnityAssets,
isMissingStudioAssetTable,
listStudioUnityAssetFiles,
} from './studio-unity-assets'
import type { StudioUnityAssetFile } from './studio-unity-assets'
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [
@@ -304,6 +311,31 @@ export async function canManageRoomById(
return canManageRoom(room, accountId)
}
/**
* Account ids that co-own a room: its creator, and every `Roles` entry at Creator or
* CoOwner. Null when the room does not exist. A pending invite is not a co-owner yet
* (`Role` stays None until they accept), and Host / Moderator are not in this set —
* the same ownership set as {@link canManageRoom}. Ordered by account id.
*
* Reads the blob only, like {@link canManageRoomById}.
*/
export async function getRoomCoOwnerIds(db: D1Database, roomId: number): Promise<number[] | null> {
const room = parseOne(
await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
.bind(roomId)
.first<RoomRow>()
)
if (!room) return null
const ids = new Set<number>()
const creator = Number(room.CreatorAccountId)
if (Number.isSafeInteger(creator) && creator > 0) ids.add(creator)
for (const role of roomRoles(room)) {
if (role.AccountId > 0 && MANAGE_ROLES.has(role.Role)) ids.add(role.AccountId)
}
return [...ids].sort((a, b) => a - b)
}
/**
* Whether an account may MODERATE a room — its creator, or the holder of a role at
* Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one
@@ -844,6 +876,47 @@ export async function setRoomImage(
.run()
}
/**
* Stamp `BecameRRStudioRoomAt` the first time a Rec Room Studio build is stored for
* this room. A room that already carries a timestamp keeps it: a later local build
* is another cloud build, not a new moment of becoming a Studio room. A missing key
* and a JSON null both count as unset (`json_type` is not `text`).
*/
export async function markRoomAsRecRoomStudio(
db: D1Database,
roomId: number,
at: string
): Promise<void> {
await db
.prepare(
`UPDATE room SET data = json_set(data, '$.BecameRRStudioRoomAt', ?2)
WHERE room_id = ?1 AND json_type(data, '$.BecameRRStudioRoomAt') IS NOT 'text'`
)
.bind(roomId, at)
.run()
}
/**
* Point one existing subroom save at a baked Unity asset, in place. Local Studio
* builds attach to the save the room is already published from; they do not append
* a new save row. Returns false when that save id is not this subroom's.
*/
export async function setSubRoomSaveUnityAssetId(
db: D1Database,
subRoomId: number,
saveId: number,
unityAssetId: string
): Promise<boolean> {
const result = await db
.prepare(
`UPDATE subroom_save SET data = json_set(data, '$.UnityAssetId', ?3)
WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2`
)
.bind(saveId, subRoomId, unityAssetId)
.run()
return (result.meta.changes ?? 0) > 0
}
/**
* Merge a set of top-level fields into a room's JSON blob and write it back. Used by
* the room-settings mutations whose values include booleans (cloning, platform
@@ -1374,10 +1447,12 @@ interface BuildSaveInput {
/**
* Build a `SubRoomDataSave` in the shape the client parses — the reference's `MapSave`
* projection. The four array fields are always empty (we neither resolve nor record
* referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when
* the save actually carried one, exactly as the reference does. There is deliberately no
* `DataBlobHash`: it is commented out of the reference DTO and absent from its output.
* projection. The array fields start empty and must be PRESENT. `UnitySubAssets` is
* filled in on read when this save's `UnityAssetId` has stored Studio bundles (see
* {@link attachStudioUnityAssets}); referenced assets stay empty. `UnityAssetId` is
* emitted only when the save actually carried one, exactly as the reference does.
* There is deliberately no `DataBlobHash` of our own: the caller stores the hash the
* client sent, and a save that carried none keeps it null.
*
* `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving
* player's live platform/device, which the save request doesn't carry and we don't track.
@@ -2159,6 +2234,52 @@ async function attachCurrentSaves(
const id = rows[i]!.current_save_id
sub.CurrentSave = id == null ? null : (byId.get(id) ?? null)
})
const saves = subs
.map((sub) => sub.CurrentSave)
.filter((save): save is SubRoomDataSave => typeof save === 'object' && save !== null)
await attachStudioUnityAssets(db, saves)
}
/**
* Fill `UnitySubAssets` on saves that point at a stored Studio build. Maker-pen
* saves have no `UnityAssetId` and are left untouched, including their empty
* arrays. A missing studio table is the same answer: the scene blob still loads,
* and the bundle list stays empty until the migration exists.
*
* Only main bundles are listed. Stripped bundles stay in the bucket.
*/
export async function attachStudioUnityAssets(
db: D1Database,
saves: SubRoomDataSave[]
): Promise<void> {
const ids = [
...new Set(
saves
.map((save) => save.UnityAssetId)
.filter((id): id is string => typeof id === 'string' && id !== '')
),
]
if (ids.length === 0) return
let files: StudioUnityAssetFile[]
try {
files = await listStudioUnityAssetFiles(db, ids)
} catch (err) {
if (isMissingStudioAssetTable(err)) return
throw err
}
const byAsset = new Map<string, StudioUnityAssetFile[]>()
for (const file of files) {
const list = byAsset.get(file.unityAssetId) ?? []
list.push(file)
byAsset.set(file.unityAssetId, list)
}
for (const save of saves) {
const id = save.UnityAssetId
if (typeof id !== 'string') continue
const baked = bakedStudioUnityAssets(byAsset.get(id) ?? [])
if (baked.length === 0) continue
save.UnitySubAssets = baked
}
}
// ---- Room tags ------------------------------------------------------------
@@ -2585,7 +2706,9 @@ export async function getSubRoomSaves(
)
.bind(subRoomId)
.all<SubRoomSaveRow>()
return results.map(parseSubRoomSaveRow)
const saves = results.map(parseSubRoomSaveRow)
await attachStudioUnityAssets(db, saves)
return saves
}
/**
@@ -2604,7 +2727,10 @@ export async function getSubRoomSaveById(
)
.bind(saveId, subRoomId)
.first<SubRoomSaveRow>()
return row ? parseSubRoomSaveRow(row) : null
if (!row) return null
const save = parseSubRoomSaveRow(row)
await attachStudioUnityAssets(db, [save])
return save
}
// ---- Subroom permissions --------------------------------------------------
+231
View File
@@ -0,0 +1,231 @@
/**
* Baked Rec Room Studio bundles, stored by the studio worker.
*
* The studio migration owns the tables. This module only reads them, and tests
* apply {@link STUDIO_UNITY_ASSET_SCHEMA_DDL} directly. A room load must keep
* working before that migration has been applied, so a missing table is
* reported by {@link isMissingStudioAssetTable} and the caller leaves the save
* the way a maker-pen room already looks.
*
* The filename column is what a client requests. New builds store a name that
* contains the unity asset id, because the download route looks the file up by
* filename alone and two rooms can otherwise both upload `win.assetbundle`.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
export const STUDIO_UNITY_ASSET_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)
)`,
]
/** The version stamped on every baked file this server stores. */
export const STUDIO_ASSET_VERSION = 1
export type StudioBundlePlatform = 'windows' | 'android'
export type StudioBundleKind = 'main' | 'stripped'
export interface StudioUnityAssetFile {
unityAssetId: string
platform: StudioBundlePlatform
kind: StudioBundleKind
filename: string
/** Lowercase hex SHA-256, the form the studio upload stores. */
sha256: string
byteLength: number
r2Key: string
}
/**
* One baked bundle as a room save lists it. PascalCase, like the rest of
* `CurrentSave`. `Target` uses the numbers the game sends as `unityAssetTarget`
* on a custom avatar: 0 is Windows, 2 is Android/Quest.
*/
export interface BakedStudioUnityAsset {
UnityAssetId: string
Target: number
Version: number
Filename: string
/** Base64 SHA-256. An empty string is not a hash. */
Hash: string
}
interface StudioUnityAssetFileRow {
unity_asset_id: string
platform: string
kind: string
filename: string
sha256: string
byte_length: number
r2_key: string
}
/**
* The name a client downloads. Unique per build, platform, and kind, so a
* filename lookup cannot return another room's bundle.
*/
export function publicStudioBundleFilename(
unityAssetId: string,
platform: string,
kind: string
): string {
const id = unityAssetId.replace(/-/g, '').toLowerCase()
return `${id}.${platform}.${kind}.assetbundle`
}
/**
* 0 for Windows, 2 for Android/Quest. Anything else is treated as Windows:
* those are the only two platforms a local build stores.
*/
export function studioAssetTarget(platform: string): number {
return platform === 'android' ? 2 : 0
}
/** Base64 SHA-256, which is the hash encoding avatar and invention downloads use. */
export function sha256HexToBase64(hex: string): string {
const clean = hex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(clean)) return ''
const bytes = new Uint8Array(32)
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
/** True when D1 failed because the studio asset tables have not been migrated. */
export function isMissingStudioAssetTable(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return message.toLowerCase().includes('no such table')
}
function toStudioUnityAssetFile(row: StudioUnityAssetFileRow): StudioUnityAssetFile | null {
if (row.platform !== 'windows' && row.platform !== 'android') return null
if (row.kind !== 'main' && row.kind !== 'stripped') return null
return {
unityAssetId: row.unity_asset_id,
platform: row.platform,
kind: row.kind,
filename: row.filename,
sha256: row.sha256,
byteLength: row.byte_length,
r2Key: row.r2_key,
}
}
/** Every stored file for the given unity asset ids. An empty id list reads nothing. */
export async function listStudioUnityAssetFiles(
db: D1Database,
unityAssetIds: string[]
): Promise<StudioUnityAssetFile[]> {
const ids = [...new Set(unityAssetIds.filter((id) => id !== ''))]
if (ids.length === 0) return []
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key
FROM studio_unity_asset_file
WHERE unity_asset_id IN (${bindPlaceholders(chunk)})`
)
.bind(...chunk)
.all<StudioUnityAssetFileRow>()
)
)
return pages
.flatMap((page) => page.results)
.map(toStudioUnityAssetFile)
.filter((file): file is StudioUnityAssetFile => file !== null)
}
/**
* The one file stored under `filename`. Null when none matches, and also when
* more than one row shares the name: serving either would hand the client the
* wrong room's bundle.
*/
export async function findStudioUnityAssetByFilename(
db: D1Database,
filename: string
): Promise<StudioUnityAssetFile | null> {
const { results } = await db
.prepare(
`SELECT unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key
FROM studio_unity_asset_file WHERE filename = ?1`
)
.bind(filename)
.all<StudioUnityAssetFileRow>()
if (results.length !== 1) return null
return toStudioUnityAssetFile(results[0]!)
}
export interface StudioUnityAssetRecord {
unityAssetId: string
/** 0 when the cloud-build row is gone. The route can fall back to the save's author. */
createdByAccountId: number
files: StudioUnityAssetFile[]
}
/**
* One unity asset and the account that built it. Null when no files are stored.
* A missing cloud-build table leaves `createdByAccountId` at 0; a missing file
* table throws, and {@link isMissingStudioAssetTable} tells the caller apart.
*/
export async function getStudioUnityAsset(
db: D1Database,
unityAssetId: string
): Promise<StudioUnityAssetRecord | null> {
const files = await listStudioUnityAssetFiles(db, [unityAssetId])
if (files.length === 0) return null
let createdByAccountId = 0
try {
const row = await db
.prepare(
`SELECT created_by_account_id FROM studio_cloud_build
WHERE unity_asset_id = ?1 ORDER BY started_at DESC LIMIT 1`
)
.bind(unityAssetId)
.first<{ created_by_account_id: number }>()
if (row) createdByAccountId = row.created_by_account_id
} catch (err) {
if (!isMissingStudioAssetTable(err)) throw err
}
return { unityAssetId, createdByAccountId, files }
}
/**
* The main bundles of these files, Windows then Android. Stripped bundles stay
* in storage and are left off this list: a client that loads every entry would
* otherwise replace the scene with the stripped one.
*/
export function bakedStudioUnityAssets(files: StudioUnityAssetFile[]): BakedStudioUnityAsset[] {
return files
.filter((file) => file.kind === 'main')
.map((file) => ({
UnityAssetId: file.unityAssetId,
Target: studioAssetTarget(file.platform),
Version: STUDIO_ASSET_VERSION,
Filename: file.filename,
Hash: sha256HexToBase64(file.sha256),
}))
.sort((a, b) => a.Target - b.Target || a.Filename.localeCompare(b.Filename))
}
@@ -1,10 +1,30 @@
import { cors } from 'hono/cors'
/** Default CORS handler */
export function withDefaultCors() {
return cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
allowHeaders: ['Content-Type', 'Authorization'],
})
import type { MiddlewareHandler } from 'hono'
const preflight = cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
allowHeaders: ['Content-Type', 'Authorization'],
})
/**
* Browser callers need `Access-Control-Allow-Origin`. Hono's `cors()` writes
* that header before `next()`, which creates `c.res`. The route's Response is
* then rebuilt with `new Response(body, res)`, and that rebuild drops
* `encodeBody`. workerd removes `Content-Length` from a null body unless
* `encodeBody` is `manual`, so a HEAD that reports a size lost the length.
* Set the header after `next()` so the route's Response stays as it was.
* OPTIONS still uses Hono's preflight response.
*/
export function withDefaultCors(): MiddlewareHandler {
return async (c, next) => {
if (c.req.method === 'OPTIONS') return preflight(c, next)
if ((c.req.header('upgrade') ?? '').toLowerCase() === 'websocket') return next()
try {
await next()
} finally {
c.res.headers.set('Access-Control-Allow-Origin', '*')
}
}
}
+12 -2
View File
@@ -185,6 +185,14 @@ const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moder
*/
const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription')
/**
* Rec Room Studio upload access, the account's `hasStudio` flag. Staff normally set it
* from the website (`/settings/recroomstudio`); this is the operator's way in. `auth`
* stamps it into the token as the `betastudio` role, so like Plus it lands on the
* player's next Studio sign-in or refresh.
*/
const grantStudio = grantRoleCommand('grant-studio', 'hasStudio', 'Rec Room Studio', 'access')
/**
* The Rec Room Plus token reload: credit every subscriber's RecCenterTokens balance by
* `amount`, in one statement. Nothing schedules this — an operator runs it when the
@@ -385,7 +393,8 @@ const lookup = new Command('lookup')
json_extract(data, '$.lastLoginTime') AS lastLoginTime,
(json_extract(data, '$.passwordHash') IS NOT NULL) AS hasPassword,
(json_extract(data, '$.isDeveloper') = 1) AS isDeveloper,
(json_extract(data, '$.isModerator') = 1) AS isModerator
(json_extract(data, '$.isModerator') = 1) AS isModerator,
(json_extract(data, '$.hasStudio') = 1) AS hasStudio
FROM account WHERE ${where}`
const res = await execSql(sql, remote)
const row = res.results[0]
@@ -399,7 +408,7 @@ const lookup = new Command('lookup')
: typeof v === 'object'
? JSON.stringify(v)
: String(v as number | string | boolean)
const boolKeys = new Set(['hasPassword', 'isDeveloper', 'isModerator'])
const boolKeys = new Set(['hasPassword', 'isDeveloper', 'isModerator', 'hasStudio'])
const table = new Table()
for (const [key, value] of Object.entries(row)) {
const shown = boolKeys.has(key) ? (value === 1 ? 'yes' : 'no') : asText(value)
@@ -418,6 +427,7 @@ export const adminCmd = new Command('admin')
.addCommand(grantDeveloper)
.addCommand(grantModerator)
.addCommand(grantPlus)
.addCommand(grantStudio)
.addCommand(reloadPlus)
.addCommand(caiLoad)
.addCommand(lookup)