diff --git a/.env.example b/.env.example index f86b9da..8e04fe4 100644 --- a/.env.example +++ b/.env.example @@ -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 --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 diff --git a/DEPLOYING.md b/DEPLOYING.md index 80217e4..9c009b5 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -140,6 +140,17 @@ wrangler secrets-store secret create --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 --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 diff --git a/apps/studio/README.md b/apps/studio/README.md index 1cdeb62..1cd235b 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -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 diff --git a/apps/studio/src/cloud-builds.ts b/apps/studio/src/cloud-builds.ts index a96c13e..5a6299d 100644 --- a/apps/studio/src/cloud-builds.ts +++ b/apps/studio/src/cloud-builds.ts @@ -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 { + 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() 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() 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) diff --git a/apps/studio/src/context.ts b/apps/studio/src/context.ts index e08e3c3..0108d8d 100644 --- a/apps/studio/src/context.ts +++ b/apps/studio/src/context.ts @@ -17,8 +17,12 @@ export type Env = SharedHonoEnv & { // Shared `recflare-cdn` bucket. Local Studio builds land under // `room/studio//`. 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. */ diff --git a/apps/studio/src/test/integration/avatar-builds.test.ts b/apps/studio/src/test/integration/avatar-builds.test.ts index 2f2b92d..2011510 100644 --- a/apps/studio/src/test/integration/avatar-builds.test.ts +++ b/apps/studio/src/test/integration/avatar-builds.test.ts @@ -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, diff --git a/apps/studio/src/test/integration/cloud-builds.test.ts b/apps/studio/src/test/integration/cloud-builds.test.ts index 9beeda1..521d8e9 100644 --- a/apps/studio/src/test/integration/cloud-builds.test.ts +++ b/apps/studio/src/test/integration/cloud-builds.test.ts @@ -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, diff --git a/apps/studio/wrangler.jsonc b/apps/studio/wrangler.jsonc index 22f8383..79ebb0f 100644 --- a/apps/studio/wrangler.jsonc +++ b/apps/studio/wrangler.jsonc @@ -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 --name CLOUD_BUILD_KEY \ + // --scopes workers --remote + { + "binding": "CLOUD_BUILD_KEY", + "store_id": "local", + "secret_name": "CLOUD_BUILD_KEY" } ], "upload_source_maps": true,