mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[studio] move cloud_build_key to secrets
This commit is contained in:
+10
-6
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user