[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
+123 -2
View File
@@ -353,6 +353,105 @@ const app = new Hono<App>()
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises. The bytes live under the key the studio upload
// wrote (`studio-room-bundles/…`), which is not the URL, so this looks the name
// up. Reads stay unauthenticated, same as `/avatar/` and `/room/`. HEAD is the
// editor's "already uploaded?" check: 200 exists, 404 does not.
.on(
'HEAD',
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Check that a Rec Room Studio room bundle exists',
description: [
'200 when `filename` names exactly one stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.',
].join(' '),
parameters: [keyParam('filename', 'The bundle filename from the room save.', true)],
responses: {
200: { description: 'The bundle exists (no body)' },
400: { description: 'The filename contains `..` (no body)' },
404: { description: 'No single stored bundle has this filename' },
},
}),
(c) => studioUnityAssetHead(c.env, c.req.param('filename'))
)
.get(
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a Rec Room Studio room bundle',
description: [
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
'on the rooms worker). The object itself lives at the R2 key the studio upload',
'recorded, so this route looks the filename up rather than using it as the key.',
'',
'A filename that matches more than one stored bundle is a 404, so two rooms that',
'uploaded the same basename cannot be served as each other. A missing studio table',
'is the same 404. The worker does not interpret the bytes.',
].join(' '),
parameters: [
keyParam('filename', 'The bundle filename from the room save.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The asset bundle'),
}),
(c) => serveUnityAsset(c)
)
/**
* The R2 key for a Studio bundle filename. Null when the name is unknown, ambiguous,
* or the studio tables have not been migrated yet.
*/
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
try {
// Same lookup as `@repo/domain` `findStudioUnityAssetByFilename`. Inlined so this
// worker does not take a dependency on the domain package. More than one row is a
// miss: two rooms must not be served each other's bundle.
const { results } = await db
.prepare(`SELECT r2_key FROM studio_unity_asset_file WHERE filename = ?1`)
.bind(filename)
.all<{ r2_key: string }>()
if (results.length !== 1) return null
return results[0]!.r2_key
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
throw err
}
}
/** GET `/unityasset/:filename` — the bytes, with the same range handling as other blobs. */
async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env.DB, name)
if (!key) return c.notFound()
return serveAsset(c, key)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env.DB, name)
if (!key) return new Response(null, { status: 404 })
const head = await env.CDN_ASSETS.head(key)
if (!head) return new Response(null, { status: 404 })
const headers = new Headers()
head.writeHttpMetadata(headers)
headers.set('etag', head.httpEtag)
headers.set('accept-ranges', 'bytes')
headers.set('cache-control', CACHE_CONTROL)
headers.set('access-control-allow-origin', '*')
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
response.headers.set('content-length', String(head.size))
return response
}
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
@@ -376,7 +475,9 @@ app.get(
'served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
'bundle: the filename is looked up in `studio_unity_asset_file` and the stored R2',
'key is streamed. `HEAD` on that path is 200 when exactly one bundle has the name.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
@@ -396,4 +497,24 @@ app.get(
)
)
export default app
/**
* What the Studio HEAD needs from the environment. Narrower than `Env` on purpose: the
* `mono` worker mounts this app with its own (superset) Env, which has no `ASSETS`
* binding, and the wrapper below must stay mountable there.
*/
type StudioAssetEnv = Pick<Env, 'DB' | 'CDN_ASSETS'>
export default {
fetch(
request: Request,
env: StudioAssetEnv,
executionCtx: ExecutionContext
): Response | Promise<Response> {
const url = new URL(request.url)
if (request.method === 'HEAD' && url.pathname.startsWith('/unityasset/')) {
const filename = decodeURIComponent(url.pathname.slice('/unityasset/'.length))
return studioUnityAssetHead(env, filename)
}
return app.fetch(request, env, executionCtx)
},
}