[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
# 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
# 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
> 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!
```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;
`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.
Apply migration `0003_studio_cloud_build_job.sql`. Create the private
`CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
`.env.example`) and run the outbound worker from `recflare/cloud-building` with the
same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
deploy; a value of `off` disables cloud building, and creation then returns an
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`
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 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 (
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,
}
}
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
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest(
@@ -188,7 +202,7 @@ const app = new Hono<App>()
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)
if (!(await cloudBuildKey(c.env))) 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))))
@@ -358,7 +372,7 @@ const app = new Hono<App>()
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)
if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
return next()
})
.post('/internal/cloud-builds/claim', async (c) => {
@@ -684,7 +698,7 @@ async function enqueueAvatar(
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
)
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)
if (pending) return c.json(ok(wire(pending)))
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
// `room/studio/<roomId>/`. 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
/**
* 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_TEMPLATE_VERSION?: string
/** R2 key of an operator-published template catalog; unset disables downloads. */
@@ -36,7 +36,7 @@ const manifest = {
}
beforeAll(async () => {
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'
for (const sql of [
...SCHEMA_DDL,
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
}
beforeAll(async () => {
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'
for (const sql of [
...ROOM_SCHEMA_DDL,
+14
View File
@@ -37,6 +37,20 @@
"binding": "JWT_SECRET",
"store_id": "local",
"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,