[studio] move cloud_build_key to secrets

This commit is contained in:
Devin Zuczek
2026-10-24 01:16:49 -04:00
parent d1e69bfd14
commit 4e2cdd87eb
8 changed files with 83 additions and 15 deletions
+21
View File
@@ -292,3 +292,24 @@ RECFLARE_DOMAIN=rec.example.com
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or # account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off. # parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000 # RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
# deploy, so create it even if you run no build host: the literal `off` disables cloud
# builds (the editor is told "Cloud building is not configured").
#
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
# --scopes workers --remote
#
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
# a mismatch leaves jobs queued forever with no error on either side — set this to the
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
+11
View File
@@ -140,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads > with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request. > the secret per request.
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
long random string in later when you set up a build host, and give that host the same
string.
```bash
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
```
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful! Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash ```bash
+10 -6
View File
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status; reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
`PUT /cloud-builds/cancel` cancels a queued or running job. `PUT /cloud-builds/cancel` cancels a queued or running job.
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private Apply migration `0003_studio_cloud_build_job.sql`. Create the private
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with `CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
the matching credential. Without a key, creation returns an explicit unavailable `.env.example`) and run the outbound worker from `recflare/cloud-building` with the
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to deploy; a value of `off` disables cloud building, and creation then returns an
the enabled targets. explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
worker claims only jobs for its own configured template version (171 by default),
so set this to match or jobs queue and are never claimed. Match installed editor
modules to the enabled targets.
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish` The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
routes lease jobs to that worker. Only validated UnityFS artifacts for the routes lease jobs to that worker. Only validated UnityFS artifacts for the
+19 -5
View File
@@ -24,7 +24,7 @@ import {
import { parseMultipart } from './multipart' import { parseMultipart } from './multipart'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { App } from './context' import type { App, Env } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job ( 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', cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
@@ -122,7 +122,21 @@ function wire(row: CloudBuildRecord) {
: null, : null,
} }
} }
async function privateAuth(request: Request, key: string | undefined) { /**
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
* when cloud building is off: the secret holds the literal `off` (the binding has to
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
* Every gate on "is cloud building configured" goes through here.
*/
export async function cloudBuildKey(env: Env): Promise<string> {
try {
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
return key === 'off' ? '' : key
} catch {
return ''
}
}
async function privateAuth(request: Request, key: string) {
if (!key) return false if (!key) return false
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`)) const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest( const got = await crypto.subtle.digest(
@@ -188,7 +202,7 @@ const app = new Hono<App>()
if (accountId === null) return c.body(null, 401) if (accountId === null) return c.body(null, 401)
if (!roles?.includes('betastudio')) if (!roles?.includes('betastudio'))
return c.json(fail('Studio upload authorization is required.'), 403) 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) if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
const form = await c.req.parseBody() const form = await c.req.parseBody()
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key])) const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id)))) if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
@@ -358,7 +372,7 @@ const app = new Hono<App>()
return c.json(ok(null)) return c.json(ok(null))
}) })
.use('/internal/cloud-builds/*', async (c, next) => { .use('/internal/cloud-builds/*', async (c, next) => {
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401) if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
return next() return next()
}) })
.post('/internal/cloud-builds/claim', async (c) => { .post('/internal/cloud-builds/claim', async (c) => {
@@ -684,7 +698,7 @@ async function enqueueAvatar(
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType)) !((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
) )
return c.body(null, 403) return c.body(null, 403)
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503) if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId) const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
if (pending) return c.json(ok(wire(pending))) if (pending) return c.json(ok(wire(pending)))
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename) const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
+6 -2
View File
@@ -17,8 +17,12 @@ export type Env = SharedHonoEnv & {
// Shared `recflare-cdn` bucket. Local Studio builds land under // Shared `recflare-cdn` bucket. Local Studio builds land under
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind. // `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket CDN_ASSETS: R2Bucket
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */ /**
CLOUD_BUILD_KEY?: string * Private pull-worker credential, out of the shared Secrets Store (same store as
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
*/
CLOUD_BUILD_KEY: SecretsStoreSecret
CLOUD_BUILD_TARGETS?: string CLOUD_BUILD_TARGETS?: string
CLOUD_BUILD_TEMPLATE_VERSION?: string CLOUD_BUILD_TEMPLATE_VERSION?: string
/** R2 key of an operator-published template catalog; unset disables downloads. */ /** R2 key of an operator-published template catalog; unset disables downloads. */
@@ -36,7 +36,7 @@ const manifest = {
} }
beforeAll(async () => { beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key') await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = 'private-avatar-worker-key' await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
env.CLOUD_BUILD_TARGETS = '0,2' env.CLOUD_BUILD_TARGETS = '0,2'
for (const sql of [ for (const sql of [
...SCHEMA_DDL, ...SCHEMA_DDL,
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
} }
beforeAll(async () => { beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key') await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = key await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
env.CLOUD_BUILD_TARGETS = '0' env.CLOUD_BUILD_TARGETS = '0'
for (const sql of [ for (const sql of [
...ROOM_SCHEMA_DDL, ...ROOM_SCHEMA_DDL,
+14
View File
@@ -37,6 +37,20 @@
"binding": "JWT_SECRET", "binding": "JWT_SECRET",
"store_id": "local", "store_id": "local",
"secret_name": "JWT_SECRET" "secret_name": "JWT_SECRET"
},
// The credential the cloud-building pull worker (recflare/cloud-building, its
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
// secret, so it lives in the same store rather than riding along as a `--var` from
// .env, which would put it in plaintext on every worker. The binding must resolve
// for this worker to deploy: create it with the value `off` to run without cloud
// builds (creation then answers 503 and the private routes refuse everyone).
//
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
// --scopes workers --remote
{
"binding": "CLOUD_BUILD_KEY",
"store_id": "local",
"secret_name": "CLOUD_BUILD_KEY"
} }
], ],
"upload_source_maps": true, "upload_source_maps": true,