[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
+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,